Compare commits

..

1 Commits

Author SHA1 Message Date
de0a538abd Update build command for organizations
Change-type: patch
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-24 12:01:24 +01:00
222 changed files with 14923 additions and 27850 deletions

View File

@ -1,37 +0,0 @@
# Reminders:
# * Matching rules are different to `.gitignore`
# * A pattern without '**' matches in the project's root directory only
# * Leading and trailing '/' are discarded (it is not possible to
# distinguish between files and directories)
# * More details: https://github.com/balena-io-modules/dockerignore
# development and testing tools or IDEs
**/*.log
**/*.pid
**/*.seed
.idea
.lock-wscript
.nvmrc
.nyc_output
.vscode
coverage
lib-cov
logs
pids
# OS cache files
**/.DS_Store
# balena CLI config and build files
**/.balenaconf
**/.fast-boot.json
**/.resinconf
balenarc.yml
build
build-bin
dist
node_modules
oclif.manifest.json
package-lock.json
resinrc.yml
tmp

2
.gitattributes vendored
View File

@ -6,7 +6,7 @@
*.sh text eol=lf
# lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows
docs/balena-cli.md text eol=lf
doc/cli.markdown text eol=lf
# crlf for the eol conversion test files
tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf
tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @balena-io/balena-cli

View File

@ -32,11 +32,11 @@ Please describe what actually happened instead:
Examples:
```
balena push myFleet
balena push myApp
balena push 192.168.0.12
balena deploy myFleet
balena deploy myFleet --build
balena build . -f myFleet
balena deploy myApp
balena deploy myApp --build
balena build . -a myApp
balena build . -A armv7hf -d raspberrypi3
```
@ -48,7 +48,7 @@ additional information. The `--logs` option reveals additional information for t
```
balena build . --logs
balena deploy myFleet --build --logs
balena deploy myApp --build --logs
```
# Steps to Reproduce the Problem

69
.gitignore vendored
View File

@ -1,36 +1,47 @@
# Reminders:
# * A pattern without '/' matches in subdirectories as well (files and directories)
# * A leading '/' anchors matching to the directory where `.gitignore` is defined
# * A trailing '/' makes the pattern match against directories only
# More details: https://git-scm.com/docs/gitignore
# development and testing tools or IDEs
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
/.idea/
/.lock-wscript
/.nvmrc
/.nyc_output/
/.vscode/
/coverage/
/lib-cov/
/logs
/pids
# OS cache files
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# balena CLI config and build files
.balenaconf
.fast-boot.json
# Coverage directory used by tools like istanbul
coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# Commenting this out is preferred by some people, see
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
node_modules
package-lock.json
.resinconf
/balenarc.yml
/build/
/build-bin/
/dist/
/node_modules
.balenaconf
resinrc.yml
balenarc.yml
.DS_Store
.idea
.nvmrc
.vscode
/tmp
build/
build-bin/
build-zip/
dist/
# Ignore fast-boot cache file
**/.fast-boot.json
/oclif.manifest.json
/package-lock.json
/resinrc.yml
/tmp/

View File

@ -1,10 +1,8 @@
module.exports = {
spec: 'tests/commands/app/create.spec.ts',
reporter: 'spec',
require: 'ts-node/register/transpile-only',
file: './tests/config-tests',
timeout: 12000,
// To test only, say, 'push.spec.ts', do it as follows so that
// requests are authenticated:
// spec: ['tests/auth/*.spec.ts', 'tests/**/deploy.spec.ts'],
spec: 'tests/**/*.spec.ts',
};

View File

@ -5,16 +5,16 @@ npm:
os: ubuntu
architecture: x86_64
node_versions:
- "10"
- "12"
- "14"
##
## Temporarily skip Alpine tests until the following issues are resolved:
## * https://github.com/concourse/concourse/issues/7905
## * https://github.com/product-os/balena-concourse/issues/631
##
# - name: linux
# os: alpine
# architecture: x86_64
# node_versions:
# - "12"
# - "14"
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "10"
- "12"
- "14"
docker:
publish: false

25
.travis.yml Normal file
View File

@ -0,0 +1,25 @@
language: node_js
os:
- linux
- osx
node_js:
- "10"
matrix:
exclude:
node_js: "10"
script:
- node --version
- npm --version
- npm run ci
# - npm run build:standalone
# - npm run build:installer
notifications:
email: false
deploy:
- provider: script
script: npm run release
skip_cleanup: true
on:
tags: true
condition: "$TRAVIS_TAG =~ ^v?[[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+"
repo: balena-io/balena-cli

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -105,12 +105,12 @@ npm run update balena-sdk ^13.0.0 major
## Editing documentation files (README, INSTALL, Reference website...)
The `docs/balena-cli.md` file is automatically generated by running `npm run build:doc` (which also
The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also
runs as part of `npm run build`). That file is then pulled by scripts in the
[balena-io/docs](https://github.com/balena-io/docs/) GitHub repo for publishing at the [CLI
Documentation page](https://www.balena.io/docs/reference/cli/).
The content sources for the auto generation of `docs/balena-cli.md` are:
The content sources for the auto generation of `doc/cli.markdown` are:
* [Selected
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
@ -120,55 +120,18 @@ The content sources for the auto generation of `docs/balena-cli.md` are:
* `lib/commands/env/add.ts`
The README file is manually edited, but subsections are automatically extracted for inclusion in
`docs/balena-cli.md` by the `getCapitanoDoc()` function in
`doc/cli.markdown` by the `getCapitanoDoc()` function in
[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
## Patches folder
The `patches` folder contains patch files created with the
[patch-package](https://www.npmjs.com/package/patch-package) tool. Small code changes to
third-party modules can be made by directly editing Javascript files under the `node_modules`
folder and then running `patch-package` to create the patch files. The patch files are then
applied immediately after `npm install`, through the `postinstall` script defined in
`package.json`.
The subfolders of the `patches` folder are documented in the
[apply-patches.js](https://github.com/balena-io/balena-cli/blob/master/patches/apply-patches.js)
script.
To make changes to the patch files under the `patches` folder, **do not edit them directly,**
not even for a "single character change" because the hash values in the patch files also need
to be recomputed by `patch-packages`. Instead, edit the relevant files under `node_modules`
directly, and then run `patch-packages` with the `--patch-dir` option to specify the subfolder
where the patch should be saved. For example, edit `node_modules/exit-hook/index.js` and then
run:
```sh
$ npx patch-package --patch-dir patches/all exit-hook
```
That said, these kinds of patches should be avoided in favour of creating pull requests
upstream. Patch files create additional maintenance work over time as the patches need to be
updated when the dependencies are updated, and they prevent the compounding community benefit
that sharing fixes upstream have on open source projects like the balena CLI. The typical
scenario where these patches are used is when the upstream maintainers are unresponsive or
unwilling to merge the required fixes, the fixes are very small and specific to the balena CLI,
and creating a fork of the upstream repo is likely to be more long-term effort than maintaining
the patches.
## Windows
Besides the regular npm installation dependencies, the `npm run build:installer` script
that produces the `.exe` graphical installer on Windows also requires
[NSIS](https://sourceforge.net/projects/nsis/) and [MSYS2](https://www.msys2.org/) to be
installed. Be sure to add `C:\Program Files (x86)\NSIS` to the PATH, so that `makensis`
is available. MSYS2 is recommended when developing the balena CLI on Windows.
If changes are made to npm scripts in `package.json`, don't assume that a Unix shell like
bash is available. For example, some Windows shells don't have the `cp` and `rm` commands,
which is why you'll often find `ncp` and `rimraf` used in `package.json` scripts.
The `npm run build:installer` script (which generates the `.exe` executable installer on Windows)
specifically requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the
standard Command Prompt or PowerShell can be used (though MSYS2 is still handy, as it provides
'git' and a number of common unix utilities). If changes are made to npm scripts in `package.json`,
check that they also run on a standard Windows Command Prompt.
## Updating the 'npm-shrinkwrap.json' file
@ -201,24 +164,6 @@ Optionally, these steps may be automated by installing the
npx npm-merge-driver install -g
```
## `fast-boot` and `npm link` - modifying the `node_modules` folder
During development or debugging, it is sometimes useful to temporarily modify the `node_modules`
folder (with or without making the respective changes to the `npm-shrinkwrap.json` file),
replacing dependencies with different versions. This can be achieved with the `npm link`
command, or by manually editing or copying files to the `node_modules` folder.
Unexpected behavior may then be observed because of the CLI's use of the
[fast-boot2](https://www.npmjs.com/package/fast-boot2) package that caches module resolution.
`fast-boot2` is configured in `lib/fast-boot.ts` to automatically invalidate the cache if
changes are made to the `package.json` or `npm-shrinkwrap.json` files, but the cache won't
be automatically invalidated if `npm link` is used or if manual modifications are made to the
`node_modules` folder. In this situation:
* Manually delete the module cache file (typically `~/.balena/cli-module-cache.json`), or
* Use the `bin/balena-dev` entry point (instead of `bin/balena`) as it does not activate
`fast-boot2`.
## TypeScript and oclif
The CLI currently contains a mix of plain JavaScript and
@ -292,11 +237,3 @@ gotchas to bear in mind:
`node_modules/balena-sdk/node_modules/balena-errors`
In the case of subclasses of `TypedError`, a string comparison may be used instead:
`error.name === 'BalenaApplicationNotFound'`
## Further debugging notes
* If you need to selectively run specific tests, `it.only` will not work in cases when authorization is required as part of the test cycle. In order to target specific tests, control execution via `.mocharc.js` instead. Here is an example of targeting the `deploy` tests.
replace: `spec: 'tests/**/*.spec.ts',`
with: `spec: ['tests/auth/*.spec.ts', 'tests/**/deploy.spec.ts'],`

View File

@ -60,7 +60,7 @@ macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
[Windows](https://www.computerhope.com/issues/ch000549.htm)
> * If you are using macOS 10.15 or later (Catalina, Big Sur), [check this known issue and
> workaround](https://github.com/balena-io/balena-cli/issues/2244).
> workaround](https://github.com/balena-io/balena-cli/issues/1479).
> * **Linux Alpine** and **Busybox:** the standalone zip package is not currently compatible with
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
> For these, consider the [NPM Installation](#npm-installation) option.
@ -76,76 +76,58 @@ as described above.
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
The npm installation involves building native (platform-specific) binary modules, which require
some development tools to be installed first, as follows.
some additional development tools to be installed first:
> **The balena CLI currently requires Node.js version 12 (min 12.8.0).**
> **Versions 13 and later are not yet fully supported.**
* [Node.js](https://nodejs.org/) version 10 (min **10.20.0**) or 12 (version 14 is not yet fully supported)
* **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
When the "system" or "default" Node.js and npm packages are installed with "apt-get" in Linux
distributions like Ubuntu, users often report permission or compilation errors when running
"npm install". This [sample
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
installation steps on an Ubuntu 18.04 base image.
* [Python 2.7](https://www.python.org/), [git](https://git-scm.com/), [make](https://www.gnu.org/software/make/), [g++](https://gcc.gnu.org/)
* **Linux** and **Windows Subsystem for Linux (WSL):**
`sudo apt-get install -y python git make g++`
* **macOS:** install Apple's Command Line Tools by running on a Terminal window:
`xcode-select --install`
### Install development tools
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
#### **Linux or WSL** (Windows Subsystem for Linux)
```sh
$ sudo apt-get update && sudo apt-get -y install curl python3 git make g++
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 12
```
The `curl` command line above uses
[nvm](https://github.com/nvm-sh/nvm/blob/master/README.md#install--update-script) to install
Node.js, instead of using `apt-get`. Installing Node.js through `apt-get` is a common source of
problems from permission errors to conflict with other system packages, and therefore not
recommended.
#### **macOS**
* Download and install Apple's Command Line Tools from https://developer.apple.com/downloads/
* Install Node.js through [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md#install--update-script):
```sh
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ . ~/.bashrc
$ nvm install 12
```
#### **Windows** (not WSL)
Install:
* Node.js v12 from the [Nodejs.org releases page](https://nodejs.org/en/download/releases/).
* If you'd like the ability to switch between Node.js versions, install
[nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows)
instead.
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++` and more:
* `pacman -S git gcc make openssh p7zip`
* Node.js from the [Nodejs.org download page](https://nodejs.org/en/download/).
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++`, `ssh`, `rsync`
and more:
* `pacman -S git openssh rsync gcc make`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based
interactive CLI menus to misbehave. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not**
necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides:
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-refreshed-wdk-for-windows-10-version-2004)
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-wdk-for-windows-10-version-1903)
* [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package,
by running the following command on an [administrator
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
`npm install --global --production windows-build-tools`
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
provides Python 2.7 and more), by running the following command on an [administrator
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
`npm install -g --production windows-build-tools`
### Install the balena CLI
After installing the development tools, install the balena CLI with:
With these dependencies in place, the balena CLI installation command is:
```sh
$ npm install balena-cli --global --production --unsafe-perm
$ npm install balena-cli -g --production --unsafe-perm
```
`--unsafe-perm` is needed when `npm install` is executed as the `root` user (e.g. in a Docker
container) in order to allow npm scripts like `postinstall` to be executed.
`--unsafe-perm` is required when `npm install` is executed as the root user, or on systems where
the global install directory is not user-writable. It allows npm install steps to download and save
prebuilt native binaries, and also allows the execution of npm scripts like `postinstall` that are
used to patch dependencies. It is usually possible to omit `--unsafe-perm` if installing under a
regular (non-root) user account, especially if using a user-managed node installation such as
[nvm](https://github.com/creationix/nvm).
## Additional Dependencies
The `balena ssh`, `scan`, `build`, `deploy` and `preload` commands may require
The `balena ssh`, `scan`, `build`, `deploy`, `preload` and `os configure` commands may require
additional software to be installed. Check the Additional Dependencies sections for each operating
system:
@ -153,9 +135,9 @@ system:
* [macOS](./INSTALL-MAC.md#additional-dependencies)
* [Linux](./INSTALL-LINUX.md#additional-dependencies)
Where Docker or balenaEngine are required, they may be installed on the local machine (where the
balena CLI is executed), on a remote server, or on a balenaOS device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images). Reasons why this
The `build` and `deploy` commands are also capable of using Docker or balenaEngine on a remote
server, or on a balenaOS device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)). Reasons why this
may be desirable include:
* To avoid having to install Docker on the development machine / laptop.
@ -163,7 +145,6 @@ may be desirable include:
* To build or run images "natively" on an ARM device, avoiding the need for QEMU emulation.
To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machine's IP address and
port number with the `--dockerHost` and `--dockerPort` command-line options. The `preload` command
has additional requirements because the bind mount feature is used. For more details, see
`balena help` for each command or the [online
port number with the `--dockerHost` and `--dockerPort` command-line options. For more details,
check `balena help build` or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).

View File

@ -1,10 +1,8 @@
# balena CLI Installation Instructions for Linux
These instructions are suitable for most Linux distributions on Intel x86, such as
Ubuntu, Debian, Fedora, Arch Linux and other glibc-based distributions.
For the ARM architecture and for Linux distributions not based on glibc, such as
Alpine Linux, follow the [NPM Installation](./INSTALL-ADVANCED.md#npm-installation)
method.
These instructions are for the recommended installation option. They are suitable for most Linux
distributions, except notably for **Linux Alpine** or **Busybox**. For these distros, see [advanced
installation options](./INSTALL-ADVANCED.md).
Selected operating system: **Linux**
@ -13,53 +11,36 @@ Selected operating system: **Linux**
with "-standalone.zip", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose, for example `/home/james`.
The extracted contents will include a `balena-cli` folder.
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add that folder (e.g. `/home/james/balena-cli`) to the `PATH` environment variable.
Check this [StackOverflow
post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix)
for instructions. Close and reopen the terminal window so that the changes to `PATH`
can take effect.
3. Add the `balena-cli` folder to the system's `PATH` environment variable. There are several
ways of achieving this on Linux: See this [StackOverflow post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix). Close and reopen the terminal window
so that the changes to PATH can take effect.
4. Check that the installation was successful by running the following commands on a
terminal window:
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy` and `preload` commands may require additional software to be installed, as described
below.
To update the balena CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## sudo configuration
A few CLI commands require execution through sudo, e.g. `sudo balena scan`.
If your Linux distribution has an `/etc/sudoers` file that defines a `secure_path`
setting, run `sudo visudo` to edit it and add the balena CLI's installation folder to
the ***pre-existing*** `secure_path` setting, for example:
```text
Defaults secure_path="/home/james/balena-cli:<pre-existing entries go here>"
```
If an `/etc/sudoers` file does not exist, or if it does not contain a pre-existing
`secure_path` setting, do not change it.
If you also have Docker installed, ensure that it can be executed ***without*** `sudo`, so that
CLI commands like `balena build` and `balena preload` can also be executed without `sudo`.
Check Docker's [post-installation
steps](https://docs.docker.com/engine/install/linux-postinstall/) on how to achieve this.
## Additional Dependencies
### build, deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh

View File

@ -10,36 +10,30 @@ Selected operating system: **macOS**
Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.
2. Double click the downloaded file to run the installer. After the installation completes,
close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful:
- [Open the Terminal
app](https://support.apple.com/en-gb/guide/terminal/apd5265185d-f365-44cb-8b09-71a064a42125/mac).
- On the terminal prompt, type `balena version` and hit Enter. It should display
the version of the balena CLI that you have installed.
3. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `build`, `deploy`
and `preload` commands may require additional software to be installed, as described
in the next section.
To update the balena CLI, repeat the steps above for the new version.
To uninstall it, run the following command on a terminal prompt:
```text
sudo /usr/local/lib/balena-cli/bin/uninstall
```
and `preload` commands may require additional software to be installed, as described below.
## Additional Dependencies
### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh
@ -58,17 +52,17 @@ command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker.
Preloading balenaOS images for some older device types (like the Raspberry
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows and macOS dropped support for the AUFS filesystem in Docker CE versions greater than
18.06.1. The present workarounds are to either:
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
present workaround is to either:
* Install the balena CLI on Linux (e.g. Ubuntu) with a virtual machine like VirtualBox.
This works because Docker for Linux still supports AUFS. Hint: if using a virtual machine,
copy the image file over, rather than accessing it through "file sharing", to avoid errors.
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types.
Long term, we are working on replacing AUFS with overlay2 for the affected device types.

View File

@ -10,17 +10,19 @@ Selected operating system: **Windows**
Look for a file name that ends with "-installer.exe":
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
2. Double click on the downloaded file to run the installer and follow the installer's
instructions.
2. Double click the downloaded file to run the installer. After the installation completes,
close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful:
- Click on the Windows Start Menu, type PowerShell, and then click
on Windows PowerShell.
- On the command prompt, type `balena version` and hit Enter. It should display
the version of the balena CLI that you have installed.
3. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy` and `preload` commands may require additional software to be installed, as
`deploy`, `preload` and `os configure` commands may require additional software to be installed, as
described below.
## Additional Dependencies
@ -28,11 +30,11 @@ described below.
### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available on a local or remote
machine. Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md#additional-dependencies) document describes other possibilities.
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh
@ -57,17 +59,24 @@ Otherwise, Bonjour for Windows can be downloaded and installed from: https://sup
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker.
Preloading balenaOS images for some older device types (like the Raspberry
Pi 3, but not the Raspberry 4) requires Docker to support the [AUFS storage
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows and macOS dropped support for the AUFS filesystem in Docker CE versions greater than
18.06.1. The present workarounds are to either:
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
present workaround is to either:
* Install the balena CLI on Linux (e.g. Ubuntu) with a virtual machine like VirtualBox.
This works because Docker for Linux still supports AUFS. Hint: if using a virtual machine,
copy the image file over, rather than accessing it through "file sharing", to avoid errors.
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
We are working on replacing AUFS with overlay2 in balenaOS images of the affected device types.
Long term, we are working on replacing AUFS with overlay2 for the affected device types.
### balena os configure
* The `balena os configure` command is currently not supported on Windows natively, but works with
the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL). When
using WSL, [install the balena CLI for
Linux](https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md).

View File

@ -28,20 +28,18 @@ are supported. Alternative shells include:
* [MSYS2](https://www.msys2.org/):
* Install additional packages with the command:
`pacman -S git gcc make openssh p7zip`
`pacman -S git openssh rsync`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based interactive CLI
menus to break. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* [MSYS](http://www.mingw.org/wiki/MSYS)
* [MSYS](http://www.mingw.org/wiki/MSYS): select the `msys-rsync` and `msys-openssh` packages too
* [Git for Windows](https://git-for-windows.github.io/)
* During the installation, you will be prompted to choose between _"Use MinTTY"_ and _"Use
Windows' default console window"._ Choose the latter, because of the same [MSYS2
bug](https://github.com/msys2/MINGW-packages/issues/1633) mentioned above (Git for Windows
actually uses MSYS2). For a screenshot, check this
[comment](https://github.com/balena-io/balena-cli/issues/598#issuecomment-556513098).
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
(WSL). In this case, a Linux distribution like Ubuntu is installed via the Microsoft Store, and a
balena CLI release **for Linux** should be selected. See
@ -50,14 +48,14 @@ are supported. Alternative shells include:
On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command
auto completion may be enabled by copying the
[balena_comp](https://github.com/balena-io/balena-cli/blob/master/completion/balena-completion.bash)
[balena-completion.bash](https://github.com/balena-io/balena-cli/blob/master/balena-completion.bash)
file to your system's `bash_completion` directory: check [Docker's command completion
guide](https://docs.docker.com/compose/completion/) for system setup instructions.
## Logging in
Several CLI commands require access to your balenaCloud account, for example in order to push a
new release to your fleet. Those commands require creating a CLI login session by running:
new release to your application. Those commands require creating a CLI login session by running:
```sh
$ balena login
@ -77,6 +75,7 @@ HTTP(S) proxies can be configured through any of the following methods, in prece
file](https://www.npmjs.com/package/balena-settings-client#documentation). It may be:
* A string in URL format, e.g. `proxy: 'http://localhost:8000'`
* An object in the format:
```yaml
proxy:
protocol: 'http'
@ -144,7 +143,7 @@ To learn more, troubleshoot issues, or to contact us for support:
* Check the [masterclass tutorials](https://www.balena.io/docs/learn/more/masterclasses/overview/)
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md)
* Ask us a question in the [balena forums](https://forums.balena.io/c/product-support)
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud)
For CLI bug reports or feature requests, check the
[CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/).
@ -156,18 +155,14 @@ of major, minor and patch version releases.
The latest release of a major version of the balena CLI will remain compatible with
the balenaCloud backend services for at least one year from the date when the
following major version is released. For example, balena CLI v11.36.0, as the
latest v11 release, would remain compatible with the balenaCloud backend for one
year from the date when v12.0.0 was released.
following major version is released. For example, balena CLI v10.17.5, as the
latest v10 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released.
Half way through to that period (6 months after the release of the next major
version), older major versions of the balena CLI will start printing a deprecation
warning message when it is used interactively (when `stderr` is attached to a TTY
device file). At the end of that period, older major versions will exit with an
error message unless the `--unsupported` flag is used. This behavior was
introduced in CLI version 12.47.0 and is also documented by `balena help`.
To take advantage of the latest backend features and ensure compatibility, users
are encouraged to regularly update the balena CLI to the latest version.
At the end of this period, the older major version is considered deprecated and
some of the functionality that depends on balenaCloud services may stop working
at any time.
Users are encouraged to regularly update the balena CLI to the latest version.
## Contributing (including editing documentation files)

View File

@ -31,11 +31,6 @@ command again.
Check whether the SD card is locked (a physical switch on the side of the card).
## I get `connect ETIMEDOUT` with `balena tunnel`
Please update the CLI to the latest version. This issue was fixed in v12.38.5.
For more details, see: https://github.com/balena-io/balena-cli/issues/2172
## I get EINVAL errors on Cygwin
The errors may look something like this:

43
appveyor.yml Normal file
View File

@ -0,0 +1,43 @@
# appveyor file
# http://www.appveyor.com/docs/appveyor-yml
image: Visual Studio 2017
init:
- git config --global core.autocrlf input
cache:
- C:\Users\appveyor\.node-gyp
- '%AppData%\npm-cache'
matrix:
fast_finish: true
# what combinations to test
environment:
matrix:
- nodejs_version: 10
install:
- ps: Install-Product node $env:nodejs_version x64
- set PATH=%APPDATA%\npm;%PATH%
- npm config set python 'C:\Python27\python.exe'
- npm --version
# - npm install
build: off
test: off
deploy: off
test_script:
- node --version
- npm --version
# - npm test
deploy_script:
- node --version
- npm --version
# - npm run build:standalone
# - npm run build:installer
# - IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release)
# - IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy')

View File

@ -17,36 +17,32 @@
import type { JsonVersions } from '../lib/commands/version';
import { run as oclifRun } from 'oclif';
import { run as oclifRun } from '@oclif/dev-cli';
import * as archiver from 'archiver';
import * as Bluebird from 'bluebird';
import { execFile } from 'child_process';
import * as filehound from 'filehound';
import { Stats } from 'fs';
import * as fs from 'fs-extra';
import * as klaw from 'klaw';
import * as _ from 'lodash';
import * as path from 'path';
import * as rimraf from 'rimraf';
import * as semver from 'semver';
import { promisify } from 'util';
import * as util from 'util';
import { stripIndent } from '../build/utils/lazy';
import { stripIndent } from '../lib/utils/lazy';
import {
diffLines,
getSubprocessStdout,
loadPackageJson,
MSYS2_BASH,
ROOT,
StdOutTap,
whichSpawn,
} from './utils';
const execFileAsync = promisify(execFile);
export const packageJSON = loadPackageJson();
export const version = 'v' + packageJSON.version;
const arch = process.arch;
const MSYS2_BASH =
process.env.MSYSSHELLPATH || 'C:\\msys64\\usr\\bin\\bash.exe';
function dPath(...paths: string[]) {
return path.join(ROOT, 'dist', ...paths);
@ -64,7 +60,7 @@ const standaloneZips: PathByPlatform = {
const oclifInstallers: PathByPlatform = {
darwin: dPath('macos', `balena-${version}.pkg`),
win32: dPath('win32', `balena-${version}-${arch}.exe`),
win32: dPath('win', `balena-${version}-${arch}.exe`),
};
const renamedOclifInstallers: PathByPlatform = {
@ -96,7 +92,6 @@ async function diffPkgOutput(pkgOut: string) {
'> pkg@',
'> Fetching base Node.js binaries',
' fetched-',
'prebuild-install WARN install No prebuilt binaries found',
];
const modulesRE =
process.platform === 'win32'
@ -248,17 +243,7 @@ async function testPkg() {
console.log(`Testing standalone package "${pkgBalenaPath}"...`);
// Run `balena version -j`, parse its stdout as JSON, and check that the
// reported Node.js major version matches semver.major(process.version)
let { stdout, stderr } = await execFileAsync(pkgBalenaPath, [
'version',
'-j',
]);
const { filterCliOutputForTests } = await import('../tests/helpers');
const filtered = filterCliOutputForTests({
err: stderr.split(/\r?\n/),
out: stdout.split(/\r?\n/),
});
stdout = filtered.out.join('\n');
stderr = filtered.err.join('\n');
const stdout = await getSubprocessStdout(pkgBalenaPath, ['version', '-j']);
let pkgNodeVersion = '';
let pkgNodeMajorVersion = 0;
try {
@ -275,10 +260,6 @@ async function testPkg() {
`Mismatched major version: built-in pkg Node version="${pkgNodeVersion}" vs process.version="${process.version}"`,
);
}
if (filtered.err.length > 0) {
const err = filtered.err.join('\n');
throw new Error(`"${pkgBalenaPath}": non-empty stderr "${err}"`);
}
console.log('Success! (standalone package test successful)');
}
@ -311,96 +292,10 @@ async function zipPkg() {
archive.on('warning', console.warn);
archive.pipe(outputStream);
archive.finalize().catch(reject);
archive.finalize();
});
}
async function signFilesForNotarization() {
console.log('Deleting unneeded zip files...');
await new Promise((resolve, reject) => {
klaw('node_modules/')
.on('data', (item: { path: string; stats: Stats }) => {
if (!item.stats.isFile()) {
return;
}
if (path.basename(item.path).endsWith('.node.bak')) {
console.log('Removing pkg .node.bak file', item.path);
fs.unlinkSync(item.path);
}
if (
path.basename(item.path).endsWith('.zip') &&
path.dirname(item.path).includes('test')
) {
console.log('Removing zip', item.path);
fs.unlinkSync(item.path);
}
})
.on('end', resolve)
.on('error', reject);
});
// Sign all .node files first
console.log('Signing .node files...');
await new Promise((resolve, reject) => {
klaw('node_modules/')
.on('data', async (item: { path: string; stats: Stats }) => {
if (!item.stats.isFile()) {
return;
}
if (path.basename(item.path).endsWith('.node')) {
console.log('running command:', 'codesign', [
'-d',
'-f',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
item.path,
]);
await whichSpawn('codesign', [
'-d',
'-f',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
item.path,
]);
}
})
.on('end', resolve)
.on('error', reject);
});
console.log('Signing other binaries...');
console.log('running command:', 'codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/denymount/bin/denymount',
]);
await whichSpawn('codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/denymount/bin/denymount',
]);
console.log('running command:', 'codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/macmount/bin/macmount',
]);
await whichSpawn('codesign', [
'-d',
'-f',
'--options=runtime',
'-s',
'Developer ID Application: Balena Ltd (66H43P8FRG)',
'node_modules/macmount/bin/macmount',
]);
}
export async function buildStandaloneZip() {
console.log(`Building standalone zip package for CLI ${version}`);
try {
@ -431,6 +326,8 @@ async function renameInstallerFiles() {
async function signWindowsInstaller() {
if (process.env.CSC_LINK && process.env.CSC_KEY_PASSWORD) {
const exeName = renamedOclifInstallers[process.platform];
const execFileAsync = util.promisify<string, string[], void>(execFile);
console.log(`Signing installer "${exeName}"`);
await execFileAsync(MSYS2_BASH, [
'sign-exe.sh',
@ -447,21 +344,7 @@ async function signWindowsInstaller() {
}
/**
* Wait for Apple Installer Notarization to continue
*/
async function notarizeMacInstaller(): Promise<void> {
const appleId = 'accounts+apple@balena.io';
const { notarize } = await import('electron-notarize');
await notarize({
appBundleId: 'io.balena.etcher',
appPath: renamedOclifInstallers.darwin,
appleId,
appleIdPassword: '@keychain:CLI_PASSWORD',
});
}
/**
* Run the `oclif pack:win` or `pack:macos` command (depending on the value
* Run the `oclif-dev pack:win` or `pack:macos` command (depending on the value
* of process.platform) to generate the native installers (which end up under
* the 'dist' folder). There are some harcoded options such as selecting only
* 64-bit binaries under Windows.
@ -486,12 +369,8 @@ export async function buildOclifInstaller() {
console.log(`rimraf(${dir})`);
await Bluebird.fromCallback((cb) => rimraf(dir, cb));
}
if (process.platform === 'darwin') {
console.log('Signing files for notarization...');
await signFilesForNotarization();
}
console.log('=======================================================');
console.log(`oclif "${packCmd}" "${packOpts.join('" "')}"`);
console.log(`oclif-dev "${packCmd}" "${packOpts.join('" "')}"`);
console.log(`cwd="${process.cwd()}" ROOT="${ROOT}"`);
console.log('=======================================================');
await oclifRun([packCmd].concat(...packOpts));
@ -502,10 +381,6 @@ export async function buildOclifInstaller() {
// (`oclif.macos.sign` section).
if (process.platform === 'win32') {
await signWindowsInstaller();
} else if (process.platform === 'darwin') {
console.log('Notarizing package...');
await notarizeMacInstaller(); // Notarize
console.log('Package notarized.');
}
console.log(`oclif installer build completed`);
}

View File

@ -34,15 +34,15 @@ const capitanoDoc = {
files: ['build/commands/api-key/generate.js'],
},
{
title: 'Fleet',
title: 'Application',
files: [
'build/commands/fleets.js',
'build/commands/fleet/index.js',
'build/commands/fleet/create.js',
'build/commands/fleet/purge.js',
'build/commands/fleet/rename.js',
'build/commands/fleet/restart.js',
'build/commands/fleet/rm.js',
'build/commands/apps.js',
'build/commands/app/index.js',
'build/commands/app/create.js',
'build/commands/app/purge.js',
'build/commands/app/rename.js',
'build/commands/app/restart.js',
'build/commands/app/rm.js',
],
},
{
@ -59,10 +59,8 @@ const capitanoDoc = {
'build/commands/devices/index.js',
'build/commands/devices/supported.js',
'build/commands/device/index.js',
'build/commands/device/deactivate.js',
'build/commands/device/identify.js',
'build/commands/device/init.js',
'build/commands/device/local-mode.js',
'build/commands/device/move.js',
'build/commands/device/os-update.js',
'build/commands/device/public-url.js',
@ -75,14 +73,6 @@ const capitanoDoc = {
'build/commands/device/shutdown.js',
],
},
{
title: 'Releases',
files: [
'build/commands/releases.js',
'build/commands/release/index.js',
'build/commands/release/finalize.js',
],
},
{
title: 'Environment Variables',
files: [

View File

@ -58,7 +58,7 @@ class FakeHelpCommand {
examples = [
'$ balena help',
'$ balena help login',
'$ balena help apps',
'$ balena help os download',
];
@ -86,7 +86,7 @@ function importOclifCommands(jsFilename: string): OclifCommand[] {
const command: OclifCommand =
jsFilename === 'help'
? (new FakeHelpCommand() as unknown as OclifCommand)
? ((new FakeHelpCommand() as unknown) as OclifCommand)
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
return [command];
@ -101,9 +101,8 @@ async function printMarkdown() {
console.log(await renderMarkdown());
} catch (error) {
console.error(error);
process.exitCode = 1;
process.exit(1);
}
}
// tslint:disable-next-line:no-floating-promises
printMarkdown();

View File

@ -24,15 +24,15 @@ const simplegit = require('simple-git/promise');
const ROOT = path.normalize(path.join(__dirname, '..'));
/**
* Compare the timestamp of balena-cli.md with the timestamp of staged files,
* issuing an error if balena-cli.md is older.
* If balena-cli.md does not require updating and the developer cannot run
* Compare the timestamp of cli.markdown with the timestamp of staged files,
* issuing an error if cli.markdown is older.
* If cli.markdown does not require updating and the developer cannot run
* `npm run build` on their laptop, the error message suggests a workaround
* using `touch`.
*/
async function checkBuildTimestamps() {
const git = simplegit(ROOT);
const docFile = path.join(ROOT, 'docs', 'balena-cli.md');
const docFile = path.join(ROOT, 'doc', 'cli.markdown');
const [docStat, gitStatus] = await Promise.all([
fs.stat(docFile),
git.status(),

View File

@ -6,8 +6,6 @@
*
* We don't `require('semver')` to allow this script to be run as a npm
* 'preinstall' hook, at which point no dependencies have been installed.
*
* @param {string} version
*/
function parseSemver(version) {
const match = /v?(\d+)\.(\d+).(\d+)/.exec(version);
@ -18,10 +16,6 @@ function parseSemver(version) {
return [parseInt(major, 10), parseInt(minor, 10), parseInt(patch, 10)];
}
/**
* @param {string} v1
* @param {string} v2
*/
function semverGte(v1, v2) {
let v1Array = parseSemver(v1);
let v2Array = parseSemver(v2);
@ -47,25 +41,17 @@ function checkNpmVersion() {
// the reason is that it would unnecessarily prevent end users from
// using npm v6.4.1 that ships with Node 8. (It is OK for the
// shrinkwrap file to get damaged if it is not going to be reused.)
throw new Error(`\
-----------------------------------------------------------------------------
console.error(`\
-------------------------------------------------------------------------------
Error: npm version '${npmVersion}' detected. Please upgrade to npm v${requiredVersion} or later
because of a bug that causes the 'npm-shrinkwrap.json' file to be damaged.
At this point, however, your 'npm-shrinkwrap.json' file has already been
damaged. Please revert it to the master branch state with a command such as:
"git checkout master -- npm-shrinkwrap.json"
Then re-run "npm install" using npm version ${requiredVersion} or later.
-----------------------------------------------------------------------------`);
-------------------------------------------------------------------------------`);
process.exit(1);
}
}
function main() {
try {
checkNpmVersion();
} catch (e) {
console.error(e.message || e);
process.exitCode = 1;
}
}
main();
checkNpmVersion();

View File

@ -54,18 +54,17 @@ export async function release() {
try {
await createGitHubRelease();
} catch (err) {
throw new Error(`Error creating GitHub release:\n${err}`);
console.error('Release failed');
console.error(err);
process.exit(1);
}
}
/** Return a cached Octokit instance, creating a new one as needed. */
const getOctokit = _.once(function () {
const Octokit = (
require('@octokit/rest') as typeof import('@octokit/rest')
).Octokit.plugin(
(
require('@octokit/plugin-throttling') as typeof import('@octokit/plugin-throttling')
).throttling,
const Octokit = (require('@octokit/rest') as typeof import('@octokit/rest')).Octokit.plugin(
(require('@octokit/plugin-throttling') as typeof import('@octokit/plugin-throttling'))
.throttling,
);
return new Octokit({
auth: GITHUB_TOKEN,
@ -111,8 +110,7 @@ function getPageNumbers(
if (!response.headers.link) {
return res;
}
const parse =
require('parse-link-header') as typeof import('parse-link-header');
const parse = require('parse-link-header') as typeof import('parse-link-header');
const parsed = parse(response.headers.link);
if (parsed == null) {
throw new Error(`Failed to parse link header: '${response.headers.link}'`);
@ -160,14 +158,11 @@ async function updateGitHubReleaseDescriptions(
per_page: perPage,
});
let errCount = 0;
type Release =
import('@octokit/rest').RestEndpointMethodTypes['repos']['listReleases']['response']['data'][0];
for await (const response of octokit.paginate.iterator<Release>(options)) {
const {
page: thisPage,
pages: totalPages,
ordinal,
} = getPageNumbers(response, perPage);
for await (const response of octokit.paginate.iterator(options)) {
const { page: thisPage, pages: totalPages, ordinal } = getPageNumbers(
response,
perPage,
);
let i = 0;
for (const cliRelease of response.data) {
const prefix = `[#${ordinal + i++} pg ${thisPage}/${totalPages}]`;

View File

@ -27,6 +27,7 @@ import {
release,
updateDescriptionOfReleasesAffectedByIssue1359,
} from './deploy-bin';
import { fixPathForMsys, ROOT, runUnderMsys } from './utils';
// DEBUG set to falsy for negative values else is truthy
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
@ -35,6 +36,11 @@ process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
? ''
: '1';
function exitWithError(error: Error | string): never {
console.error(`Error: ${error}`);
process.exit(1);
}
/**
* Trivial command-line parser. Check whether the command-line argument is one
* of the following strings, then call the appropriate functions:
@ -42,14 +48,17 @@ process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
* 'build:standalone' (to build a standalone pkg package)
* 'release' (to create/update a GitHub release)
*
* In the case of 'build:installer', also call runUnderMsys() to switch the
* shell from cmd.exe to MSYS2 bash.exe.
*
* @param args Arguments to parse (default is process.argv.slice(2))
*/
async function parse(args?: string[]) {
export async function run(args?: string[]) {
args = args || process.argv.slice(2);
console.error(`[debug] automation/run.ts process.argv=[${process.argv}]`);
console.error(`[debug] automation/run.ts args=[${args}]`);
console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
console.log(`automation/run.ts args=[${args}]`);
if (_.isEmpty(args)) {
throw new Error('missing command-line arguments');
return exitWithError('missing command-line arguments');
}
const commands: { [cmd: string]: () => void | Promise<void> } = {
'build:installer': buildOclifInstaller,
@ -61,10 +70,14 @@ async function parse(args?: string[]) {
};
for (const arg of args) {
if (!commands.hasOwnProperty(arg)) {
throw new Error(`command unknown: ${arg}`);
return exitWithError(`command unknown: ${arg}`);
}
}
// If runUnderMsys() is called to re-execute this script under MSYS2,
// the current working dir becomes the MSYS2 homedir, so we change back.
process.chdir(ROOT);
// The BUILD_TMP env var is used as an alternative location for oclif
// (patched) to copy/extract the CLI files, run npm install and then
// create the NSIS executable installer for Windows. This was necessary
@ -82,26 +95,29 @@ async function parse(args?: string[]) {
for (const arg of args) {
try {
if (arg === 'build:installer' && process.platform === 'win32') {
// ensure running under MSYS2
if (!process.env.MSYSTEM) {
process.env.MSYS2_PATH_TYPE = 'inherit';
await runUnderMsys([
fixPathForMsys(process.argv[0]),
fixPathForMsys(process.argv[1]),
arg,
]);
continue;
}
if (process.env.MSYS2_PATH_TYPE !== 'inherit') {
throw new Error(
'the MSYS2_PATH_TYPE env var must be set to "inherit"',
);
}
}
const cmdFunc = commands[arg];
await cmdFunc();
} catch (err) {
if (typeof err === 'object') {
err.message = `"${arg}": ${err.message}`;
}
throw err;
return exitWithError(`"${arg}": ${err}`);
}
}
}
/** See jsdoc for parse() function above */
export async function run(args?: string[]) {
try {
await parse(args);
} catch (e) {
console.error(e.message ? `Error: ${e.message}` : e);
process.exitCode = 1;
}
}
// tslint:disable-next-line:no-floating-promises
run();

View File

@ -10,10 +10,7 @@ npm i
if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then
rm npm-shrinkwrap.json.old
echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **";
echo "** This can usually be fixed with: **";
echo "** git checkout master -- npm-shrinkwrap.json **";
echo "** rm -rf node_modules **";
echo "** npm install && npm dedupe && npm install **";
echo "** Please run 'npm ci', followed by 'npm dedupe' **";
exit 1;
fi

View File

@ -11,7 +11,8 @@ const validateChangeType = (maybeChangeType: string = 'minor') => {
case 'major':
return maybeChangeType;
default:
throw new Error(`Invalid change type: '${maybeChangeType}'`);
console.error(`Invalid change type: '${maybeChangeType}'`);
return process.exit(1);
}
};
@ -36,8 +37,8 @@ const run = async (cmd: string) => {
}
resolve({ stdout, stderr });
});
p.stdout?.pipe(process.stdout);
p.stderr?.pipe(process.stderr);
p.stdout.pipe(process.stdout);
p.stderr.pipe(process.stderr);
});
};
@ -57,24 +58,31 @@ const getUpstreams = async () => {
const repoYaml = fs.readFileSync(__dirname + '/../repo.yml', 'utf8');
const yaml = await import('js-yaml');
const { upstream } = yaml.load(repoYaml) as {
const { upstream } = yaml.safeLoad(repoYaml) as {
upstream: Upstream[];
};
return upstream;
};
const getUsage = (upstreams: Upstream[], upstreamName: string) => `
const printUsage = (upstreams: Upstream[], upstreamName: string) => {
console.error(
`
Usage: npm run update ${upstreamName} $version [$changeType=minor]
Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')}
`;
`,
);
return process.exit(1);
};
async function $main() {
// TODO: Drop the wrapper function once we move to TS 3.8,
// which will support top level await.
async function main() {
const upstreams = await getUpstreams();
if (process.argv.length < 3) {
throw new Error(getUsage(upstreams, '$upstreamName'));
return printUsage(upstreams, '$upstreamName');
}
const upstreamName = process.argv[2];
@ -82,15 +90,16 @@ async function $main() {
const upstream = upstreams.find((v) => v.repo === upstreamName);
if (!upstream) {
throw new Error(
console.error(
`Invalid upstream name '${upstreamName}', valid options: ${upstreams
.map(({ repo }) => repo)
.join(', ')}`,
);
return process.exit(1);
}
if (process.argv.length < 4) {
throw new Error(getUsage(upstreams, upstreamName));
printUsage(upstreams, upstreamName);
}
const packageName = upstream.module || upstream.repo;
@ -99,7 +108,8 @@ async function $main() {
await run(`npm install ${packageName}@${process.argv[3]}`);
const newVersion = await getVersion(packageName);
if (newVersion === oldVersion) {
throw new Error(`Already on version '${newVersion}'`);
console.error(`Already on version '${newVersion}'`);
return process.exit(1);
}
console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`);
@ -127,14 +137,4 @@ async function $main() {
);
}
async function main() {
try {
await $main();
} catch (e) {
console.error(e);
process.exitCode = 1;
}
}
// tslint:disable-next-line:no-floating-promises
main();

View File

@ -18,7 +18,10 @@
import { spawn } from 'child_process';
import * as _ from 'lodash';
import * as path from 'path';
import * as shellEscape from 'shell-escape';
export const MSYS2_BASH =
process.env.MSYSSHELLPATH || 'C:\\msys64\\usr\\bin\\bash.exe';
export const ROOT = path.join(__dirname, '..');
/** Tap and buffer this process' stdout and stderr */
@ -88,6 +91,93 @@ export function loadPackageJson() {
return require(path.join(ROOT, 'package.json'));
}
/**
* Convert e.g. 'C:\myfolder' -> '/C/myfolder' so that the path can be given
* as argument to "unix tools" like 'tar' under MSYS or MSYS2 on Windows.
*/
export function fixPathForMsys(p: string): string {
return p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1');
}
/**
* Run the MSYS2 bash.exe shell in a child process (child_process.spawn()).
* The given argv arguments are escaped using the 'shell-escape' package,
* so that backslashes in Windows paths, and other bash-special characters,
* are preserved. If argv is not provided, defaults to process.argv, to the
* effect that this current (parent) process is re-executed under MSYS2 bash.
* This is useful to change the default shell from cmd.exe to MSYS2 bash on
* Windows.
* @param argv Arguments to be shell-escaped and given to MSYS2 bash.exe.
*/
export async function runUnderMsys(argv?: string[]) {
const newArgv = argv || process.argv;
await new Promise((resolve, reject) => {
const args = ['-lc', shellEscape(newArgv)];
const child = spawn(MSYS2_BASH, args, { stdio: 'inherit' });
child.on('close', (code) => {
if (code) {
console.log(`runUnderMsys: child process exited with code ${code}`);
reject(code);
} else {
resolve();
}
});
});
}
/**
* Run the executable at execPath as a child process, and resolve a promise
* to the executable's stdout output as a string. Reject the promise if
* anything is printed to stderr, or if the child process exits with a
* non-zero exit code.
* @param execPath Executable path
* @param args Command-line argument for the executable
*/
export async function getSubprocessStdout(
execPath: string,
args: string[],
): Promise<string> {
const child = spawn(execPath, args);
return new Promise((resolve, reject) => {
let stdout = '';
child.stdout.on('error', reject);
child.stderr.on('error', reject);
child.stdout.on('data', (data: Buffer) => {
try {
stdout = data.toString();
} catch (err) {
reject(err);
}
});
child.stderr.on('data', (data: Buffer) => {
try {
const stderr = data.toString();
// ignore any debug lines, but ensure that we parse
// every line provided to the stderr stream
const lines = _.filter(
stderr.trim().split(/\r?\n/),
(line) => !line.startsWith('[debug]'),
);
if (lines.length > 0) {
reject(
new Error(`"${execPath}": non-empty stderr "${lines.join('\n')}"`),
);
}
} catch (err) {
reject(err);
}
});
child.on('exit', (code: number) => {
if (code) {
reject(new Error(`"${execPath}": non-zero exit code "${code}"`));
} else {
resolve(stdout);
}
});
});
}
/**
* Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified
@ -117,7 +207,7 @@ export async function which(program: string): Promise<string> {
*/
export async function whichSpawn(
programName: string,
args: string[] = [],
args?: string[],
): Promise<void> {
const program = await which(programName);
let error: Error | undefined;

73
balena-completion.bash Normal file
View File

@ -0,0 +1,73 @@
#!/bin/bash
_balena_complete()
{
local cur prev
# Valid top-level completions
commands="app apps build config deploy device devices env envs help key \
keys local login logout logs note os preload quickstart settings \
scan ssh util version whoami"
# Sub-completions
app_cmds="create restart rm"
config_cmds="generate inject read reconfigure write"
device_cmds="identify init move public-url reboot register rename rm \
shutdown"
device_public_url_cmds="disable enable status"
env_cmds="add rename rm"
key_cmds="add rm"
local_cmds="configure flash"
os_cmds="build-config configure download initialize versions"
util_cmds="available-drives"
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
"app")
COMPREPLY=( $(compgen -W "$app_cmds" -- $cur) )
;;
"config")
COMPREPLY=( $(compgen -W "$config_cmds" -- $cur) )
;;
"device")
COMPREPLY=( $(compgen -W "$device_cmds" -- $cur) )
;;
"env")
COMPREPLY=( $(compgen -W "$env_cmds" -- $cur) )
;;
"key")
COMPREPLY=( $(compgen -W "$key_cmds" -- $cur) )
;;
"local")
COMPREPLY=( $(compgen -W "$local_cmds" -- $cur) )
;;
"os")
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
;;
"util")
COMPREPLY=( $(compgen -W "$util_cmds" -- $cur) )
;;
"*")
;;
esac
elif [ $COMP_CWORD -eq 3 ]
then
case "$prev" in
"public-url")
COMPREPLY=( $(compgen -W "$device_public_url_cmds" -- $cur) )
;;
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -9,15 +9,14 @@ process.env.UV_THREADPOOL_SIZE = '64';
// Disable oclif registering ts-node
process.env.OCLIF_TS_NODE = 0;
async function run() {
// Use fast-boot to cache require lookups, speeding up startup
await require('../build/fast-boot').start();
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
cacheFile: __dirname + '/.fast-boot.json',
});
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
// Run the CLI
await require('../build/app').run();
}
run();
// Run the CLI
require('../build/app').run();

View File

@ -11,22 +11,6 @@
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
// Note on `fast-boot2`: We do not use `fast-boot2` with `balena-dev` because:
// * fast-boot2's cacheKiller option is configured to include the timestamps of
// the package.json and npm-shrinkwrap.json files, to avoid unexpected CLI
// behavior when changes are made to dependencies during development. This is
// generally a good thing, however, `balena-dev` (a few lines below) edits
// `package.json` to modify oclif paths, and this results in cache
// invalidation and a performance hit rather than speedup.
// * Even if the timestamps are removed from cacheKiller, so that there is no
// cache invalidation, fast-boot's speedup is barely noticeable when ts-node
// is used, e.g. 1.43s vs 1.4s when running `balena version`.
// * `fast-boot` causes unexpected behavior when used with `npm link` or
// when the `node_modules` folder is manually modified (affecting transitive
// dependencies) during development (e.g. bug investigations). A workaround
// is to use `balena-dev` without `fast-boot`. See also notes in
// `CONTRIBUTING.md`.
const path = require('path');
const rootDir = path.join(__dirname, '..');
@ -47,6 +31,12 @@ process.on('SIGINT', function () {
process.exit();
});
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
cacheFile: '.fast-boot.json',
});
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');

View File

@ -1,83 +0,0 @@
#compdef balena
#autoload
#GENERATED FILE DON'T MODIFY#
_balena() {
typeset -A opt_args
local context state line curcontext="$curcontext"
# Valid top-level completions
main_commands=( build deploy envs fleets join keys leave login logout logs note orgs preload push releases scan settings ssh support tags tunnel version whoami api-key config device device devices env fleet fleet internal key key local os release release tag util )
# Sub-completions
api_key_cmds=( generate )
config_cmds=( generate inject read reconfigure write )
device_cmds=( deactivate identify init local-mode move os-update public-url purge reboot register rename restart rm shutdown )
devices_cmds=( supported )
env_cmds=( add rename rm )
fleet_cmds=( create purge rename restart rm )
internal_cmds=( osinit )
key_cmds=( add rm )
local_cmds=( configure flash )
os_cmds=( build-config configure download initialize versions )
release_cmds=( finalize )
tag_cmds=( rm set )
_arguments -C \
'(- 1 *)--version[show version and exit]' \
'(- 1 *)'{-h,--help}'[show help options and exit]' \
'1:first command:_balena_main_cmds' \
'2:second command:_balena_sec_cmds' \
&& ret=0
}
(( $+functions[_balena_main_cmds] )) ||
_balena_main_cmds() {
_describe -t main_commands 'command' main_commands "$@" && ret=0
}
(( $+functions[_balena_sec_cmds] )) ||
_balena_sec_cmds() {
case $line[1] in
"api-key")
_describe -t api_key_cmds 'api-key_cmd' api_key_cmds "$@" && ret=0
;;
"config")
_describe -t config_cmds 'config_cmd' config_cmds "$@" && ret=0
;;
"device")
_describe -t device_cmds 'device_cmd' device_cmds "$@" && ret=0
;;
"devices")
_describe -t devices_cmds 'devices_cmd' devices_cmds "$@" && ret=0
;;
"env")
_describe -t env_cmds 'env_cmd' env_cmds "$@" && ret=0
;;
"fleet")
_describe -t fleet_cmds 'fleet_cmd' fleet_cmds "$@" && ret=0
;;
"internal")
_describe -t internal_cmds 'internal_cmd' internal_cmds "$@" && ret=0
;;
"key")
_describe -t key_cmds 'key_cmd' key_cmds "$@" && ret=0
;;
"local")
_describe -t local_cmds 'local_cmd' local_cmds "$@" && ret=0
;;
"os")
_describe -t os_cmds 'os_cmd' os_cmds "$@" && ret=0
;;
"release")
_describe -t release_cmds 'release_cmd' release_cmds "$@" && ret=0
;;
"tag")
_describe -t tag_cmds 'tag_cmd' tag_cmds "$@" && ret=0
;;
esac
}
_balena "$@"

View File

@ -1,80 +0,0 @@
#!/bin/bash
#GENERATED FILE DON'T MODIFY#
_balena_complete()
{
local cur prev
# Valid top-level completions
main_commands="build deploy envs fleets join keys leave login logout logs note orgs preload push releases scan settings ssh support tags tunnel version whoami api-key config device device devices env fleet fleet internal key key local os release release tag util"
# Sub-completions
api_key_cmds="generate"
config_cmds="generate inject read reconfigure write"
device_cmds="deactivate identify init local-mode move os-update public-url purge reboot register rename restart rm shutdown"
devices_cmds="supported"
env_cmds="add rename rm"
fleet_cmds="create purge rename restart rm"
internal_cmds="osinit"
key_cmds="add rm"
local_cmds="configure flash"
os_cmds="build-config configure download initialize versions"
release_cmds="finalize"
tag_cmds="rm set"
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${main_commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
api-key)
COMPREPLY=( $(compgen -W "$api_key_cmds" -- $cur) )
;;
config)
COMPREPLY=( $(compgen -W "$config_cmds" -- $cur) )
;;
device)
COMPREPLY=( $(compgen -W "$device_cmds" -- $cur) )
;;
devices)
COMPREPLY=( $(compgen -W "$devices_cmds" -- $cur) )
;;
env)
COMPREPLY=( $(compgen -W "$env_cmds" -- $cur) )
;;
fleet)
COMPREPLY=( $(compgen -W "$fleet_cmds" -- $cur) )
;;
internal)
COMPREPLY=( $(compgen -W "$internal_cmds" -- $cur) )
;;
key)
COMPREPLY=( $(compgen -W "$key_cmds" -- $cur) )
;;
local)
COMPREPLY=( $(compgen -W "$local_cmds" -- $cur) )
;;
os)
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
;;
release)
COMPREPLY=( $(compgen -W "$release_cmds" -- $cur) )
;;
tag)
COMPREPLY=( $(compgen -W "$tag_cmds" -- $cur) )
;;
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -1,175 +0,0 @@
/**
* @license
* Copyright 2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('path');
const rootDir = path.join(__dirname, '..');
const fs = require('fs');
const manifestFile = 'oclif.manifest.json';
commandsFilePath = path.join(rootDir, manifestFile);
if (fs.existsSync(commandsFilePath)) {
console.log('Generating shell auto completion files...');
} else {
console.error(`generate-completion.js: Could not find "${manifestFile}"`);
process.exitCode = 1;
return;
}
const commandsJson = JSON.parse(fs.readFileSync(commandsFilePath, 'utf8'));
var mainCommands = [];
var additionalCommands = [];
for (const key of Object.keys(commandsJson.commands)) {
const cmd = key.split(':');
if (cmd.length > 1) {
additionalCommands.push(cmd);
if (!mainCommands.includes(cmd[0])) {
mainCommands.push(cmd[0]);
}
} else {
mainCommands.push(cmd[0]);
}
}
const mainCommandsStr = mainCommands.join(' ');
// GENERATE BASH COMPLETION FILE
bashFilePathIn = path.join(__dirname, '/templates/bash.template');
bashFilePathOut = path.join(__dirname, 'balena-completion.bash');
try {
fs.unlinkSync(bashFilePathOut);
} catch (error) {
process.exitCode = 1;
return console.error(error);
}
fs.readFile(bashFilePathIn, 'utf8', function (err, data) {
if (err) {
process.exitCode = 1;
return console.error(err);
}
data = data.replace(
'#TEMPLATE FILE FOR BASH COMPLETION#',
"#GENERATED FILE DON'T MODIFY#",
);
data = data.replace(
/\$main_commands\$/g,
'main_commands="' + mainCommandsStr + '"',
);
var subCommands = [];
var prevElement = additionalCommands[0][0];
additionalCommands.forEach(function (element) {
if (element[0] === prevElement) {
subCommands.push(element[1]);
} else {
const prevElement2 = prevElement.replace(/-/g, '_') + '_cmds';
data = data.replace(
/\$sub_cmds\$/g,
' ' + prevElement2 + '="' + subCommands.join(' ') + '"\n$sub_cmds$',
);
data = data.replace(
/\$sub_cmds_prev\$/g,
' ' +
prevElement +
')\n COMPREPLY=( $(compgen -W "$' +
prevElement2 +
'" -- $cur) )\n ;;\n$sub_cmds_prev$',
);
prevElement = element[0];
subCommands = [];
subCommands.push(element[1]);
}
});
// cleanup placeholders
data = data.replace(/\$sub_cmds\$/g, '');
data = data.replace(/\$sub_cmds_prev\$/g, '');
fs.writeFile(bashFilePathOut, data, 'utf8', function (error) {
if (error) {
process.exitCode = 1;
return console.error(error);
}
});
});
// GENERATE ZSH COMPLETION FILE
zshFilePathIn = path.join(__dirname, '/templates/zsh.template');
zshFilePathOut = path.join(__dirname, '_balena');
try {
fs.unlinkSync(zshFilePathOut);
} catch (error) {
process.exitCode = 1;
return console.error(error);
}
fs.readFile(zshFilePathIn, 'utf8', function (err, data) {
if (err) {
process.exitCode = 1;
return console.error(err);
}
data = data.replace(
'#TEMPLATE FILE FOR ZSH COMPLETION#',
"#GENERATED FILE DON'T MODIFY#",
);
data = data.replace(
/\$main_commands\$/g,
'main_commands=( ' + mainCommandsStr + ' )',
);
var subCommands = [];
var prevElement = additionalCommands[0][0];
additionalCommands.forEach(function (element) {
if (element[0] === prevElement) {
subCommands.push(element[1]);
} else {
const prevElement2 = prevElement.replace(/-/g, '_') + '_cmds';
data = data.replace(
/\$sub_cmds\$/g,
' ' + prevElement2 + '=( ' + subCommands.join(' ') + ' )\n$sub_cmds$',
);
data = data.replace(
/\$sub_cmds_prev\$/g,
' "' +
prevElement +
'")\n _describe -t ' +
prevElement2 +
" '" +
prevElement +
"_cmd' " +
prevElement2 +
' "$@" && ret=0\n ;;\n$sub_cmds_prev$',
);
prevElement = element[0];
subCommands = [];
subCommands.push(element[1]);
}
});
// cleanup placeholders
data = data.replace(/\$sub_cmds\$/g, '');
data = data.replace(/\$sub_cmds_prev\$/g, '');
fs.writeFile(zshFilePathOut, data, 'utf8', function (error) {
if (error) {
process.exitCode = 1;
return console.error(error);
}
});
});

View File

@ -1,32 +0,0 @@
#!/bin/bash
#TEMPLATE FILE FOR BASH COMPLETION#
_balena_complete()
{
local cur prev
# Valid top-level completions
$main_commands$
# Sub-completions
$sub_cmds$
COMPREPLY=()
cur=${COMP_WORDS[COMP_CWORD]}
prev=${COMP_WORDS[COMP_CWORD-1]}
if [ $COMP_CWORD -eq 1 ]
then
COMPREPLY=( $(compgen -W "${main_commands}" -- $cur) )
elif [ $COMP_CWORD -eq 2 ]
then
case "$prev" in
$sub_cmds_prev$
"*")
;;
esac
fi
}
complete -F _balena_complete balena

View File

@ -1,35 +0,0 @@
#compdef balena
#autoload
#TEMPLATE FILE FOR ZSH COMPLETION#
_balena() {
typeset -A opt_args
local context state line curcontext="$curcontext"
# Valid top-level completions
$main_commands$
# Sub-completions
$sub_cmds$
_arguments -C \
'(- 1 *)--version[show version and exit]' \
'(- 1 *)'{-h,--help}'[show help options and exit]' \
'1:first command:_balena_main_cmds' \
'2:second command:_balena_sec_cmds' \
&& ret=0
}
(( $+functions[_balena_main_cmds] )) ||
_balena_main_cmds() {
_describe -t main_commands 'command' main_commands "$@" && ret=0
}
(( $+functions[_balena_sec_cmds] )) ||
_balena_sec_cmds() {
case $line[1] in
$sub_cmds_prev$
esac
}
_balena "$@"

112
doc/automated-init.md Normal file
View File

@ -0,0 +1,112 @@
# Provisioning balena devices in automated (non-interactive) mode
This document describes how to run the `device init` command in non-interactive mode.
It requires collecting some preliminary information _once_.
The final command to provision the device looks like this:
```bash
balena device init --app APP_ID --os-version OS_VERSION --drive DRIVE --config CONFIG_FILE --yes
```
You can run this command as many times as you need, putting the new medium (SD card / USB stick) each time.
But before you can run it you need to collect the parameters and build the configuration file. Keep reading to figure out how to do it.
## Collect all the required parameters.
1. `DEVICE_TYPE`. Run
```bash
balena devices supported
```
and find the _slug_ for your target device type, like _raspberrypi3_.
1. `APP_ID`. Create an application (`balena app create APP_NAME --type DEVICE_TYPE`) or find an existing one (`balena apps`) and notice its ID.
1. `OS_VERSION`. Run
```bash
balena os versions DEVICE_TYPE
```
and pick the version that you need, like _v2.0.6+rev1.prod_.
_Note_ that even though we support _semver ranges_ it's recommended to use the exact version when doing the automated provisioning as it
guarantees full compatibility between the steps.
1. `DRIVE`. Plug in your target medium (SD card or the USB stick, depending on your device type) and run
```bash
balena util available-drives
```
and get the drive name, like _/dev/sdb_ or _/dev/mmcblk0_.
The balena CLI will not display the system drives to protect you,
but still please check very carefully that you've picked the correct drive as it will be erased during the provisioning process.
Now we have all the parameters -- time to build the config file.
## Build the config file
Interactive device provisioning process often includes collecting some extra device configuration, like the networking mode and wifi credentials.
To skip this interactive step we need to buid this configuration once and save it to the JSON file for later reuse.
Let's say we will place it into the `CONFIG_FILE` path, like _./balena-os/raspberrypi3-config.json_.
We also need to put the OS image somewhere, let's call this path `OS_IMAGE_PATH`, it can be something like _./balena-os/raspberrypi3-v2.0.6+rev1.prod.img_.
1. First we need to download the OS image once. That's needed for building the config, and will speedup the subsequent operations as the downloaded OS image is placed into the local cache.
Run:
```bash
balena os download DEVICE_TYPE --output OS_IMAGE_PATH --version OS_VERSION
```
1. Now we're ready to build the config:
```bash
balena os build-config OS_IMAGE_PATH DEVICE_TYPE --output CONFIG_FILE
```
This will run you through the interactive configuration wizard and in the end save the generated config as `CONFIG_FILE`. You can then verify it's not empty:
```bash
cat CONFIG_FILE
```
## Done
Now you're ready to run the command in the beginning of this guide.
Please note again that all of these steps only need to be done once (unless you need to change something), and once all the parameters are collected the main init command can be run unchanged.
But there are still some nuances to cover, please read below.
## Nuances
### `sudo` password on *nix systems
In order to write the image to the raw device we need the root permissions, this is unavoidable.
To improve the security we only run the minimal subcommand with `sudo`.
This means that with the default setup you're interrupted closer to the end of the device init process to enter your sudo password for this subcommand to work.
There are several ways to eliminate it and make the process fully non-interactive.
#### Option 1: make passwordless sudo.
Obviously you shouldn't do that if the machine you're working on has access to any sensitive resources or information.
But if you're using a machine dedicated to balena provisioning this can be fine, and also the simplest thing to do.
#### Option 2: `NOPASSWD` directive
You can configure the `balena` CLI command to be sudo-runnable without the password. Check [this post](https://askubuntu.com/questions/159007/how-do-i-run-specific-sudo-commands-without-a-password) for an example.
### Extra initialization config
As of June 2017 all the supported devices should not require any other interactive configuration.
But by the design of our system it is _possible_ (though it doesn't look very likely it's going to happen any time soon) that some extra initialization options may be requested for the specific device types.
If that is the case please raise the issue in the balena CLI repository and the maintainers will add the necessary options to build the similar JSON config for this step.

File diff suppressed because it is too large Load Diff

15
gulpfile.js Normal file
View File

@ -0,0 +1,15 @@
const gulp = require('gulp');
const inlinesource = require('gulp-inline-source');
const OPTIONS = {
files: {
pages: 'lib/auth/pages/*.ejs',
},
};
gulp.task('pages', () =>
gulp
.src(OPTIONS.files.pages)
.pipe(inlinesource())
.pipe(gulp.dest('build/auth/pages')),
);

View File

@ -16,14 +16,8 @@
*/
import * as packageJSON from '../package.json';
import {
AppOptions,
checkDeletedCommand,
preparseArgs,
unsupportedFlag,
} from './preparser';
import { CliSettings } from './utils/bootstrap';
import { onceAsync } from './utils/lazy';
import { onceAsync, stripIndent } from './utils/lazy';
/**
* Sentry.io setup
@ -33,7 +27,6 @@ export const setupSentry = onceAsync(async () => {
const config = await import('./config');
const Sentry = await import('@sentry/node');
Sentry.init({
autoSessionTracking: false,
dsn: config.sentryDsn,
release: packageJSON.version,
});
@ -50,8 +43,13 @@ export const setupSentry = onceAsync(async () => {
async function checkNodeVersion() {
const validNodeVersions = packageJSON.engines.node;
if (!(await import('semver')).satisfies(process.version, validNodeVersions)) {
const { getNodeEngineVersionWarn } = await import('./utils/messages');
console.warn(getNodeEngineVersionWarn(process.version, validNodeVersions));
console.warn(stripIndent`
------------------------------------------------------------------------------
Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}".
This may cause unexpected behavior. To upgrade Node, visit:
https://nodejs.org/en/download/
------------------------------------------------------------------------------
`);
}
}
@ -77,13 +75,11 @@ export function setMaxListeners(maxListeners: number) {
/** Selected CLI initialization steps */
async function init() {
if (process.env.BALENARC_NO_SENTRY) {
if (process.env.DEBUG) {
console.error(`WARN: disabling Sentry.io error reporting`);
}
console.error(`WARN: disabling Sentry.io error reporting`);
} else {
await setupSentry();
}
await checkNodeVersion();
checkNodeVersion();
const settings = new CliSettings();
@ -93,70 +89,43 @@ async function init() {
setupBalenaSdkSharedOptions(settings);
// check for CLI updates once a day
if (!process.env.BALENARC_OFFLINE_MODE) {
(await import('./utils/update')).notify();
}
(await import('./utils/update')).notify();
}
/** Execute the oclif parser and the CLI command. */
async function oclifRun(command: string[], options: AppOptions) {
let deprecationPromise: Promise<void>;
// check and enforce the CLI's deprecation policy
if (unsupportedFlag || process.env.BALENARC_UNSUPPORTED) {
deprecationPromise = Promise.resolve();
} else {
const { DeprecationChecker } = await import('./deprecation');
const deprecationChecker = new DeprecationChecker(packageJSON.version);
// warnAndAbortIfDeprecated uses previously cached data only
await deprecationChecker.warnAndAbortIfDeprecated();
// checkForNewReleasesIfNeeded may query the npm registry
deprecationPromise = deprecationChecker.checkForNewReleasesIfNeeded();
}
const runPromise = (async function (shouldFlush: boolean) {
const { CustomMain } = await import('./utils/oclif-utils');
let isEEXIT = false;
try {
await CustomMain.run(command);
} catch (error) {
// oclif sometimes exits with ExitError code EEXIT 0 (not an error),
// for example the `balena help` command.
async function oclifRun(
command: string[],
options: import('./preparser').AppOptions,
) {
const { CustomMain } = await import('./utils/oclif-utils');
const runPromise = CustomMain.run(command).then(
() => {
if (!options.noFlush) {
return require('@oclif/command/flush');
}
},
(error) => {
// oclif sometimes exits with ExitError code 0 (not an error)
// (Avoid `error instanceof ExitError` here for the reasons explained
// in the CONTRIBUTING.md file regarding the `instanceof` operator.)
if (error.oclif?.exit === 0) {
isEEXIT = true;
return;
} else {
throw error;
}
}
if (shouldFlush) {
await import('@oclif/command/flush');
}
// TODO: figure out why we need to call fast-boot stop() here, in
// addition to calling it in the main `run()` function in this file.
// If it is not called here as well, there is a process exit delay of
// 1 second when the fast-boot2 cache is modified (1 second is the
// default cache saving timeout). Try for example `balena help`.
// I have found that, when oclif's `Error: EEXIT: 0` is caught in
// the try/catch block above, execution does not get past the
// Promise.all() call below, but I don't understand why.
if (isEEXIT) {
(await import('./fast-boot')).stop();
}
})(!options.noFlush);
},
);
const { trackPromise } = await import('./hooks/prerun/track');
await Promise.all([trackPromise, deprecationPromise, runPromise]);
await Promise.all([trackPromise, runPromise]);
}
/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
export async function run(cliArgs = process.argv, options: AppOptions = {}) {
export async function run(
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
try {
const { setOfflineModeEnvVars, normalizeEnvVars, pkgExec } = await import(
'./utils/bootstrap'
);
setOfflineModeEnvVars();
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
normalizeEnvVars();
// The 'pkgExec' special/internal command provides a Node.js interpreter
@ -167,6 +136,8 @@ export async function run(cliArgs = process.argv, options: AppOptions = {}) {
await init();
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
// Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cliArgs.slice(2));
@ -175,13 +146,6 @@ export async function run(cliArgs = process.argv, options: AppOptions = {}) {
} catch (err) {
await (await import('./errors')).handleError(err);
} finally {
try {
(await import('./fast-boot')).stop();
} catch (e) {
if (process.env.DEBUG) {
console.error(`[debug] Stopping fast-boot: ${e}`);
}
}
// Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause();
}

View File

@ -56,7 +56,7 @@ export async function login({ host = '127.0.0.1', port = 0 }) {
console.info(`Opening web browser for URL:\n${loginUrl}`);
const open = await import('open');
await open(loginUrl, { wait: false });
open(loginUrl, { wait: false });
const balena = getBalenaSdk();
const token = await loginServer.awaitForToken();

View File

@ -59,15 +59,14 @@ export class LoginServer extends EventEmitter {
app.set('views', path.join(__dirname, 'pages'));
this.server = await new Promise<import('net').Server>((resolve, reject) => {
const callback = (err: Error) => {
const server = app.listen(port, host, (err: Error) => {
if (err) {
this.emit('error', err);
reject(err);
} else {
resolve(server);
}
};
const server = app.listen(port, host, callback as any);
});
server.on('connection', (socket) => this.serverSockets.push(socket));
});

View File

@ -14,44 +14,57 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as _ from 'lodash';
import * as url from 'url';
import { getBalenaSdk } from '../utils/lazy';
/**
* Get dashboard CLI login URL
* @summary Get dashboard CLI login URL
* @function
* @protected
*
* @param callbackUrl - Callback url, e.g. 'http://127.0.0.1:3000'
* @returns Dashboard login URL, e.g.:
* 'https://dashboard.balena-cloud.com/login/cli/http%253A%252F%252F127.0.0.1%253A59581%252Fauth'
* @param {String} callbackUrl - callback url
* @fulfil {String} - dashboard login url
* @returns {Promise}
*
* @example
* utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
* console.log(url)
*/
export async function getDashboardLoginURL(
callbackUrl: string,
): Promise<string> {
export const getDashboardLoginURL = (callbackUrl: string) => {
// Encode percentages signs from the escaped url
// characters to avoid angular getting confused.
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25');
const [{ URL }, dashboardUrl] = await Promise.all([
import('url'),
getBalenaSdk().settings.get('dashboardUrl'),
]);
return new URL(`/login/cli/${callbackUrl}`, dashboardUrl).href;
}
return getBalenaSdk()
.settings.get('dashboardUrl')
.then((dashboardUrl) =>
url.resolve(dashboardUrl, `/login/cli/${callbackUrl}`),
);
};
/**
* Log in using a token, but only if the token is valid.
* @summary Log in using a token, but only if the token is valid
* @function
* @protected
*
* @description
* This function checks that the token is not only well-structured
* but that it also authenticates with the server successfully.
*
* If authenticated, the token is persisted, if not then the previous
* login state is restored.
*
* @param token - session token or api key
* @returns whether the login was successful or not
* @param {String} token - session token or api key
* @fulfil {Boolean} - whether the login was successful or not
* @returns {Promise}
*
* utils.loginIfTokenValid('...').then (loggedIn) ->
* if loggedIn
* console.log('Token is valid!')
*/
export async function loginIfTokenValid(token?: string): Promise<boolean> {
token = (token || '').trim();
if (!token) {
export const loginIfTokenValid = async (token: string): Promise<boolean> => {
if (_.isEmpty(token?.trim())) {
return false;
}
const balena = getBalenaSdk();
@ -73,4 +86,4 @@ export async function loginIfTokenValid(token?: string): Promise<boolean> {
}
}
return isLoggedIn;
}
};

View File

@ -16,12 +16,7 @@
*/
import Command from '@oclif/command';
import {
InsufficientPrivilegesError,
NotAvailableInOfflineModeError,
} from './errors';
import { stripIndent } from './utils/lazy';
import * as output from './framework/output';
import { InsufficientPrivilegesError } from './errors';
export default abstract class BalenaCommand extends Command {
/**
@ -45,13 +40,6 @@ export default abstract class BalenaCommand extends Command {
*/
public static authenticated = false;
/**
* Require an internet connection to run.
* When set to true, command will exit with an error
* if user is running in offline mode (BALENARC_OFFLINE_MODE).
*/
public static offlineCompatible = false;
/**
* Accept piped input.
* When set to true, command will read from stdin during init
@ -109,29 +97,6 @@ export default abstract class BalenaCommand extends Command {
}
}
/**
* Throw NotAvailableInOfflineModeError if in offline mode.
*
* Called automatically if `onlineOnly=true`.
* Can be called explicitly by command implementation, if e.g.:
* - check should only be done conditionally
* - other code needs to execute before check
*
* Note, currently public to allow use outside of derived commands
* (as some command implementations require this. Can be made protected
* if this changes).
*
* @throws {NotAvailableInOfflineModeError}
*/
public static checkNotUsingOfflineMode() {
if (process.env.BALENARC_OFFLINE_MODE) {
throw new NotAvailableInOfflineModeError(stripIndent`
This command requires an internet connection, and cannot be used in offline mode.
To leave offline mode, unset the BALENARC_OFFLINE_MODE environment variable.
`);
}
}
/**
* Read stdin contents and make available to command.
*
@ -160,15 +125,8 @@ export default abstract class BalenaCommand extends Command {
await BalenaCommand.checkLoggedIn();
}
if (!ctr.offlineCompatible) {
BalenaCommand.checkNotUsingOfflineMode();
}
if (ctr.readStdin) {
await this.getStdin();
}
}
protected outputMessage = output.outputMessage;
protected outputData = output.outputData;
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,12 +16,11 @@
*/
import { flags } from '@oclif/command';
import type { Application } from 'balena-sdk';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import type { Application } from 'balena-sdk';
interface FlagsDef {
organization?: string;
@ -33,18 +32,18 @@ interface ArgsDef {
name: string;
}
export default class FleetCreateCmd extends Command {
export default class AppCreateCmd extends Command {
public static description = stripIndent`
Create a fleet.
Create an application.
Create a new balena fleet.
Create a new balena application.
You can specify the organization the fleet should belong to using
You can specify the organization the application should belong to using
the \`--organization\` option. The organization's handle, not its name,
should be provided. Organization handles can be listed with the
\`balena orgs\` command.
The fleet's default device type is specified with the \`--type\` option.
The application's default device type is specified with the \`--type\` option.
The \`balena devices supported\` command can be used to list the available
device types.
@ -56,30 +55,31 @@ export default class FleetCreateCmd extends Command {
`;
public static examples = [
'$ balena fleet create MyFleet',
'$ balena fleet create MyFleet --organization mmyorg',
'$ balena fleet create MyFleet -o myorg --type raspberry-pi',
'$ balena app create MyApp',
'$ balena app create MyApp --organization mmyorg',
'$ balena app create MyApp -o myorg --type raspberry-pi',
];
public static args = [
{
name: 'name',
description: 'fleet name',
description: 'application name',
required: true,
},
];
public static usage = 'fleet create <name>';
public static usage = 'app create <name>';
public static flags: flags.Input<FlagsDef> = {
organization: flags.string({
char: 'o',
description: 'handle of the organization the fleet should belong to',
description:
'handle of the organization the application should belong to',
}),
type: flags.string({
char: 't',
description:
'fleet device type (Check available types with `balena devices supported`)',
'application device type (Check available types with `balena devices supported`)',
}),
help: cf.help,
};
@ -88,7 +88,7 @@ export default class FleetCreateCmd extends Command {
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
FleetCreateCmd,
AppCreateCmd,
);
// Ascertain device type
@ -112,12 +112,12 @@ export default class FleetCreateCmd extends Command {
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: fleet "${params.name}" already exists in organization "${organization}".`,
`Error: application "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create fleets in organization "${organization}".`,
`Error: You are not authorized to create applications in organization "${organization}".`,
);
}
@ -125,8 +125,11 @@ export default class FleetCreateCmd extends Command {
}
// Output
const { isV13 } = await import('../../utils/version');
console.log(
`Fleet created: slug "${application.slug}", device type "${deviceType}"`,
isV13()
? `Application created: slug "${application.slug}", device type "${deviceType}"`
: `Application created: ${application.slug} (${deviceType}, id ${application.id})`,
);
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -15,63 +15,58 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import type { Release } from 'balena-sdk';
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
import { isV14 } from '../../utils/version';
import type { DataOutputOptions } from '../../framework';
import type { Release } from 'balena-sdk';
interface FlagsDef extends DataOutputOptions {
interface FlagsDef {
help: void;
}
interface ArgsDef {
fleet: string;
application: string;
}
export default class FleetCmd extends Command {
export default class AppCmd extends Command {
public static description = stripIndent`
Display information about a single fleet.
Display information about a single application.
Display detailed information about a single fleet.
Display detailed information about a single balena application.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena fleet MyFleet',
'$ balena fleet myorg/myfleet',
];
public static examples = ['$ balena app MyApp', '$ balena app myorg/myapp'];
public static args = [ca.fleetRequired];
public static args = [ca.applicationRequired];
public static usage = 'fleet <fleet>';
public static usage = 'app <nameOrSlug>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
...(isV14() ? cf.dataOutputFlags : {}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
FleetCmd,
);
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppCmd);
const { getApplication } = await import('../../utils/sdk');
const application = (await getApplication(getBalenaSdk(), params.fleet, {
$expand: {
is_for__device_type: { $select: 'slug' },
should_be_running__release: { $select: 'commit' },
const application = (await getApplication(
getBalenaSdk(),
params.application,
{
$expand: {
is_for__device_type: { $select: 'slug' },
should_be_running__release: { $select: 'commit' },
},
},
})) as ApplicationWithDeviceType & {
)) as ApplicationWithDeviceType & {
should_be_running__release: [Release?];
// For display purposes:
device_type: string;
@ -81,23 +76,15 @@ export default class FleetCmd extends Command {
application.device_type = application.is_for__device_type[0].slug;
application.commit = application.should_be_running__release[0]?.commit;
if (isV14()) {
await this.outputData(
application,
['app_name', 'id', 'device_type', 'slug', 'commit'],
options,
);
} else {
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${application.slug}`);
console.log(
getVisuals().table.vertical(application, [
'id',
'device_type',
'slug',
'commit',
]),
);
}
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${application.app_name}`);
console.log(
getVisuals().table.vertical(application, [
'id',
'device_type',
'slug',
'commit',
]),
);
}
}

View File

@ -15,8 +15,7 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
@ -28,27 +27,27 @@ interface FlagsDef {
}
interface ArgsDef {
fleet: string;
application: string;
}
export default class FleetPurgeCmd extends Command {
export default class AppPurgeCmd extends Command {
public static description = stripIndent`
Purge data from a fleet.
Purge data from an application.
Purge data from all devices belonging to a fleet.
This will clear the fleet's '/data' directory.
Purge data from all devices belonging to an application.
This will clear the application's /data directory.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena fleet purge MyFleet',
'$ balena fleet purge myorg/myfleet',
'$ balena app purge MyApp',
'$ balena app purge myorg/myapp',
];
public static args = [ca.fleetRequired];
public static args = [ca.applicationRequired];
public static usage = 'fleet purge <fleet>';
public static usage = 'app purge <application>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -57,7 +56,7 @@ export default class FleetPurgeCmd extends Command {
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(FleetPurgeCmd);
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppPurgeCmd);
const { getApplication } = await import('../../utils/sdk');
@ -65,7 +64,7 @@ export default class FleetPurgeCmd extends Command {
// balena.models.application.purge only accepts a numeric id
// so we must first fetch the app to get it's id,
const application = await getApplication(balena, params.fleet);
const application = await getApplication(balena, params.application);
try {
await balena.models.application.purge(application.id);

View File

@ -15,29 +15,28 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import type { ApplicationType } from 'balena-sdk';
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
import type { ApplicationType } from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
fleet: string;
application: string;
newName?: string;
}
export default class FleetRenameCmd extends Command {
export default class AppRenameCmd extends Command {
public static description = stripIndent`
Rename a fleet.
Rename an application.
Rename a fleet.
Rename an application.
Note, if the \`newName\` parameter is omitted, it will be
prompted for interactively.
@ -46,20 +45,20 @@ export default class FleetRenameCmd extends Command {
`;
public static examples = [
'$ balena fleet rename OldName',
'$ balena fleet rename OldName NewName',
'$ balena fleet rename myorg/oldname NewName',
'$ balena app rename OldName',
'$ balena app rename OldName NewName',
'$ balena app rename myorg/oldname NewName',
];
public static args = [
ca.fleetRequired,
ca.applicationRequired,
{
name: 'newName',
description: 'the new name for the fleet',
description: 'the new name for the application',
},
];
public static usage = 'fleet rename <fleet> [newName]';
public static usage = 'app rename <application> [newName]';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -68,7 +67,7 @@ export default class FleetRenameCmd extends Command {
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(FleetRenameCmd);
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
const { validateApplicationName } = await import('../../utils/validation');
const { ExpectedError } = await import('../../errors');
@ -77,7 +76,7 @@ export default class FleetRenameCmd extends Command {
// Disambiguate target application (if params.params is a number, it could either be an ID or a numerical name)
const { getApplication } = await import('../../utils/sdk');
const application = await getApplication(balena, params.fleet, {
const application = await getApplication(balena, params.application, {
$expand: {
application_type: {
$select: ['is_legacy'],
@ -87,14 +86,16 @@ export default class FleetRenameCmd extends Command {
// Check app exists
if (!application) {
throw new ExpectedError(`Error: fleet ${params.fleet} not found.`);
throw new ExpectedError(
'Error: application ${params.nameOrSlug} not found.',
);
}
// Check app supports renaming
const appType = (application.application_type as ApplicationType[])?.[0];
if (appType.is_legacy) {
throw new ExpectedError(
`Fleet ${params.fleet} is of 'legacy' type, and cannot be renamed.`,
`Application ${params.application} is of 'legacy' type, and cannot be renamed.`,
);
}
@ -102,31 +103,20 @@ export default class FleetRenameCmd extends Command {
const newName =
params.newName ||
(await getCliForm().ask({
message: 'Please enter the new name for this fleet:',
message: 'Please enter the new name for this application:',
type: 'input',
validate: validateApplicationName,
})) ||
'';
// Check they haven't used slug in new name
if (newName.includes('/')) {
throw new ExpectedError(
`New fleet name cannot include '/', please check that you are not specifying fleet slug.`,
);
}
// Rename
try {
await balena.models.application.rename(application.id, newName);
} catch (e) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
if ((e.message || '').toLowerCase().includes('unique')) {
throw new ExpectedError(`Error: fleet ${newName} already exists.`);
}
// BalenaRequestError: Request error: App name may only contain [a-zA-Z0-9_-].
if ((e.message || '').toLowerCase().includes('name may only contain')) {
throw new ExpectedError(
`Error: new fleet name may only include characters [a-zA-Z0-9_-].`,
`Error: application ${params.application} already exists.`,
);
}
throw e;
@ -138,7 +128,7 @@ export default class FleetRenameCmd extends Command {
);
// Output result
console.log(`Fleet renamed`);
console.log(`Application renamed`);
console.log('From:');
console.log(`\tname: ${application.app_name}`);
console.log(`\tslug: ${application.slug}`);

View File

@ -15,8 +15,7 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
@ -28,26 +27,26 @@ interface FlagsDef {
}
interface ArgsDef {
fleet: string;
application: string;
}
export default class FleetRestartCmd extends Command {
export default class AppRestartCmd extends Command {
public static description = stripIndent`
Restart a fleet.
Restart an application.
Restart all devices belonging to a fleet.
Restart all devices belonging to an application.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena fleet restart MyFleet',
'$ balena fleet restart myorg/myfleet',
'$ balena app restart MyApp',
'$ balena app restart myorg/myapp',
];
public static args = [ca.fleetRequired];
public static args = [ca.applicationRequired];
public static usage = 'fleet restart <fleet>';
public static usage = 'app restart <application>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -56,14 +55,14 @@ export default class FleetRestartCmd extends Command {
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(FleetRestartCmd);
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const application = await getApplication(balena, params.fleet);
const application = await getApplication(balena, params.application);
await balena.models.application.restart(application.id);
}

View File

@ -15,8 +15,7 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
@ -29,14 +28,14 @@ interface FlagsDef {
}
interface ArgsDef {
fleet: string;
application: string;
}
export default class FleetRmCmd extends Command {
export default class AppRmCmd extends Command {
public static description = stripIndent`
Remove a fleet.
Remove an application.
Permanently remove a fleet.
Permanently remove a balena application.
The --yes option may be used to avoid interactive confirmation.
@ -44,14 +43,14 @@ export default class FleetRmCmd extends Command {
`;
public static examples = [
'$ balena fleet rm MyFleet',
'$ balena fleet rm MyFleet --yes',
'$ balena fleet rm myorg/myfleet',
'$ balena app rm MyApp',
'$ balena app rm MyApp --yes',
'$ balena app rm myorg/myapp',
];
public static args = [ca.fleetRequired];
public static args = [ca.applicationRequired];
public static usage = 'fleet rm <fleet>';
public static usage = 'app rm <application>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes,
@ -62,7 +61,7 @@ export default class FleetRmCmd extends Command {
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
FleetRmCmd,
AppRmCmd,
);
const { confirm } = await import('../../utils/patterns');
@ -72,11 +71,11 @@ export default class FleetRmCmd extends Command {
// Confirm
await confirm(
options.yes ?? false,
`Are you sure you want to delete fleet ${params.fleet}?`,
`Are you sure you want to delete application ${params.application}?`,
);
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const application = await getApplication(balena, params.fleet);
const application = await getApplication(balena, params.application);
// Remove
await balena.models.application.remove(application.id);

96
lib/commands/apps.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
interface ExtendedApplication extends ApplicationWithDeviceType {
device_count?: number;
online_devices?: number;
}
interface FlagsDef {
help: void;
verbose: boolean;
}
export default class AppsCmd extends Command {
public static description = stripIndent`
List all applications.
list all your balena applications.
For detailed information on a particular application,
use \`balena app <application>\` instead.
`;
public static examples = ['$ balena apps'];
public static usage = 'apps';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
verbose: flags.boolean({
default: false,
char: 'v',
description: 'No-op since release v12.0.0',
}),
};
public static authenticated = true;
public static primary = true;
public async run() {
this.parse<FlagsDef, {}>(AppsCmd);
const balena = getBalenaSdk();
// Get applications
const applications = (await balena.models.application.getAll({
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
})) as ExtendedApplication[];
const _ = await import('lodash');
// Add extended properties
applications.forEach((application) => {
application.device_count = application.owns__device?.length ?? 0;
application.online_devices = _.sumBy(application.owns__device, (d) =>
d.is_online === true ? 1 : 0,
);
// @ts-expect-error
application.device_type = application.is_for__device_type[0].slug;
});
// Display
console.log(
getVisuals().table.horizontal(applications, [
'id',
'app_name',
'slug',
'device_type',
'online_devices',
'device_count',
]),
);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -17,24 +17,20 @@
import { flags } from '@oclif/command';
import Command from '../command';
import { getBalenaSdk } from '../utils/lazy';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import * as compose from '../utils/compose';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
import {
buildArgDeprecation,
dockerignoreHelp,
registrySecretsHelp,
} from '../utils/messages';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { ComposeCliFlags, ComposeOpts } from '../utils/compose-types';
import { buildProject, composeCliFlags } from '../utils/compose_ts';
import type { BuildOpts, DockerCliFlags } from '../utils/docker';
import { dockerCliFlags } from '../utils/docker';
import { lowercaseIfSlug } from '../utils/normalization';
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
arch?: string;
deviceType?: string;
fleet?: string;
application?: string;
source?: string; // Not part of command profile - source param copied here.
help: void;
}
@ -44,34 +40,35 @@ interface ArgsDef {
}
export default class BuildCmd extends Command {
public static description = `\
Build a project locally.
public static description = stripIndent`
Build a project locally.
Use this command to build an image or a complete multicontainer project with
the provided docker daemon in your development machine or balena device.
(See also the \`balena push\` command for the option of building images in the
balenaCloud build servers.)
Use this command to build an image or a complete multicontainer project with
the provided docker daemon in your development machine or balena device.
(See also the \`balena push\` command for the option of building images in the
balenaCloud build servers.)
You must specify either a fleet, or the device type and architecture.
You must provide either an application or a device-type/architecture pair.
This command will look into the given source directory (or the current working
directory if one isn't specified) for a docker-compose.yml file, and if found,
each service defined in the compose file will be built. If a compose file isn't
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
will try to generate one.
This command will look into the given source directory (or the current working
directory if one isn't specified) for a docker-compose.yml file, and if found,
each service defined in the compose file will be built. If a compose file isn't
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
will try to generate one.
${registrySecretsHelp}
${registrySecretsHelp}.split('\n').join('\n\t\t')}
${dockerignoreHelp}.split('\n').join('\n\t\t')}
`;
${dockerignoreHelp}
`;
public static examples = [
'$ balena build --fleet myFleet',
'$ balena build ./source/ --fleet myorg/myfleet',
'$ balena build --application myApp',
'$ balena build ./source/ --application myApp',
'$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated',
'$ balena build --docker /var/run/docker.sock --fleet myFleet # Linux, Mac',
'$ balena build --docker //./pipe/docker_engine --fleet myFleet # Windows',
'$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -f myFleet',
'$ balena build --docker /var/run/docker.sock --application myApp # Linux, Mac',
'$ balena build --docker //./pipe/docker_engine --application myApp # Windows',
'$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -a myApp',
];
public static args = [
@ -92,7 +89,11 @@ ${dockerignoreHelp}
description: 'the type of device this build is for',
char: 'd',
}),
fleet: cf.fleet,
application: flags.string({
description: 'name or slug of the target application this build is for',
char: 'a',
parse: lowercaseIfSlug,
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
@ -107,8 +108,10 @@ ${dockerignoreHelp}
BuildCmd,
);
await Command.checkLoggedInIf(!!options.fleet);
await Command.checkLoggedInIf(!!options.application);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const sdk = getBalenaSdk();
@ -122,12 +125,7 @@ ${dockerignoreHelp}
await this.validateOptions(options, sdk);
// Build args are under consideration for removal - warn user
if (options.buildArg) {
console.log(buildArgDeprecation);
}
const app = await this.getAppAndResolveArch(options);
const app = await this.getAppAndResolveArch(sdk, options);
const { docker, buildOpts, composeOpts } = await this.prepareBuild(options);
@ -151,12 +149,14 @@ ${dockerignoreHelp}
protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) {
// Validate option combinations
if (
(opts.fleet == null && (opts.arch == null || opts.deviceType == null)) ||
(opts.fleet != null && (opts.arch != null || opts.deviceType != null))
(opts.application == null &&
(opts.arch == null || opts.deviceType == null)) ||
(opts.application != null &&
(opts.arch != null || opts.deviceType != null))
) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
'You must specify either a fleet (-f), or the device type (-d) and architecture (-A)',
'You must specify either an application or an arch/deviceType pair to build for',
);
}
@ -176,10 +176,10 @@ ${dockerignoreHelp}
opts['registry-secrets'] = registrySecrets;
}
protected async getAppAndResolveArch(opts: FlagsDef) {
if (opts.fleet) {
const { getAppWithArch } = await import('../utils/helpers');
const app = await getAppWithArch(opts.fleet);
protected async getAppAndResolveArch(sdk: BalenaSDK, opts: FlagsDef) {
if (opts.application) {
const { getAppWithArch } = await import('../utils/sdk');
const app = await getAppWithArch(sdk, opts.application);
opts.arch = app.arch;
opts.deviceType = app.is_for__device_type[0].slug;
return app;
@ -214,7 +214,7 @@ ${dockerignoreHelp}
* @param opts
*/
protected async buildProject(
docker: import('dockerode'),
docker: import('docker-toolbelt'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
@ -227,12 +227,7 @@ ${dockerignoreHelp}
) {
const { loadProject } = await import('../utils/compose_ts');
const project = await loadProject(
logger,
composeOpts,
undefined,
opts.buildOpts.t,
);
const project = await loadProject(logger, composeOpts);
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
if (
@ -241,7 +236,7 @@ ${dockerignoreHelp}
!appType.supports_multicontainer
) {
logger.logWarn(
'Target fleet does not support multiple containers.\n' +
'Target application does not support multiple containers.\n' +
'Continuing with build, but you will not be able to deploy.',
);
}
@ -259,6 +254,7 @@ ${dockerignoreHelp}
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
}

View File

@ -19,13 +19,13 @@ import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliForm, stripIndent } from '../../utils/lazy';
import { applicationIdInfo, devModeInfo } from '../../utils/messages';
import { applicationIdInfo } from '../../utils/messages';
import type { PineDeferred } from 'balena-sdk';
interface FlagsDef {
version: string; // OS version
fleet?: string;
dev?: boolean; // balenaOS development variant
application?: string;
app?: string; // application alias
device?: string;
deviceApiKey?: string;
deviceType?: string;
@ -36,7 +36,6 @@ interface FlagsDef {
wifiSsid?: string;
wifiKey?: string;
appUpdatePollInterval?: string;
'provisioning-key-name'?: string;
help: void;
}
@ -44,17 +43,16 @@ export default class ConfigGenerateCmd extends Command {
public static description = stripIndent`
Generate a config.json file.
Generate a config.json file for a device or fleet.
Generate a config.json file for a device or application.
The target balenaOS version must be specified with the --version option.
Calling this command with the exact version number of the targeted image is required.
${devModeInfo.split('\n').join('\n\t\t')}
This command is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions
that will be asked for the relevant device type.
To configure an image for a fleet of mixed device types, use the --fleet option
alongside the --deviceType option to specify the target device type.
To avoid interactive questions, specify a command line option for each question that
would otherwise be asked.
In case that you want to configure an image for an application with mixed device types,
you can pass the --deviceType argument along with --application to specify the target device type.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
@ -62,12 +60,13 @@ export default class ConfigGenerateCmd extends Command {
public static examples = [
'$ balena config generate --device 7cf02a6 --version 2.12.7',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --generate-device-api-key',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --deviceApiKey <existingDeviceKey>',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --device-api-key <existingDeviceKey>',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --output config.json',
'$ balena config generate --fleet myorg/fleet --version 2.12.7 --dev',
'$ balena config generate --fleet myorg/fleet --version 2.12.7 --deviceType fincm3',
'$ balena config generate --fleet myorg/fleet --version 2.12.7 --output config.json',
'$ balena config generate --fleet myorg/fleet --version 2.12.7 --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 15',
'$ balena config generate --app MyApp --version 2.12.7',
'$ balena config generate --app myorg/myapp --version 2.12.7',
'$ balena config generate --app MyApp --version 2.12.7 --deviceType fincm3',
'$ balena config generate --app MyApp --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7 --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1',
];
public static usage = 'config generate';
@ -77,20 +76,20 @@ export default class ConfigGenerateCmd extends Command {
description: 'a balenaOS version',
required: true,
}),
fleet: { ...cf.fleet, exclusive: ['device'] },
dev: cf.dev,
device: {
...cf.device,
exclusive: ['fleet', 'provisioning-key-name'],
},
application: { ...cf.application, exclusive: ['app', 'device'] },
app: { ...cf.app, exclusive: ['application', 'device'] },
device: flags.string({
description: 'device uuid',
char: 'd',
exclusive: ['application', 'app'],
}),
deviceApiKey: flags.string({
description:
'custom device key - note that this is only supported on balenaOS 2.0.3+',
char: 'k',
}),
deviceType: flags.string({
description:
"device type slug (run 'balena devices supported' for possible values)",
description: 'device type slug',
}),
'generate-device-api-key': flags.boolean({
description: 'generate a fresh device key for the device',
@ -114,11 +113,7 @@ export default class ConfigGenerateCmd extends Command {
}),
appUpdatePollInterval: flags.string({
description:
'supervisor cloud polling interval in minutes (e.g. for device variables)',
}),
'provisioning-key-name': flags.string({
description: 'custom key name assigned to generated provisioning api key',
exclusive: ['device'],
'how frequently (in minutes) to poll for application updates',
}),
help: cf.help,
};
@ -148,8 +143,8 @@ export default class ConfigGenerateCmd extends Command {
if (!rawDevice.belongs_to__application) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(stripIndent`
Device ${options.device} does not appear to belong to an accessible fleet.
Try with a different device, or use '--fleet' instead of '--device'.`);
Device ${options.device} does not appear to belong to an accessible application.
Try with a different device, or use '--application' instead of '--device'.`);
}
device = rawDevice as DeviceWithDeviceType & {
belongs_to__application: PineDeferred;
@ -157,7 +152,7 @@ export default class ConfigGenerateCmd extends Command {
resourceDeviceType = device.is_of__device_type[0].slug;
} else {
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
application = (await getApplication(balena, options.fleet!, {
application = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
@ -172,7 +167,7 @@ export default class ConfigGenerateCmd extends Command {
);
// Check compatibility if application and deviceType provided
if (options.fleet && options.deviceType) {
if (options.application && options.deviceType) {
const appDeviceManifest = await balena.models.device.getManifestBySlug(
resourceDeviceType,
);
@ -182,7 +177,7 @@ export default class ConfigGenerateCmd extends Command {
!helpers.areDeviceTypesCompatible(appDeviceManifest, deviceManifest)
) {
throw new balena.errors.BalenaInvalidDeviceType(
`Device type ${options.deviceType} is incompatible with fleet ${options.fleet}`,
`Device type ${options.deviceType} is incompatible with application ${options.application}`,
);
}
}
@ -191,11 +186,9 @@ export default class ConfigGenerateCmd extends Command {
// Pass params as an override: if there is any param with exactly the same name as a
// required option, that value is used (and the corresponding question is not asked)
const answers = await getCliForm().run(deviceManifest.options, {
override: { ...options, app: options.fleet, application: options.fleet },
override: options,
});
answers.version = options.version;
answers.developmentMode = options.dev;
answers.provisioningKeyName = options['provisioning-key-name'];
// Generate config
const { generateDeviceConfig, generateApplicationConfig } = await import(
@ -225,7 +218,7 @@ export default class ConfigGenerateCmd extends Command {
}
protected readonly missingDeviceOrAppMessage = stripIndent`
Either a device or a fleet must be specified.
Either a device or an application must be specified.
See the help page for examples:
@ -233,19 +226,21 @@ export default class ConfigGenerateCmd extends Command {
`;
protected readonly deviceTypeNotAllowedMessage =
'The --deviceType option can only be used alongside the --fleet option';
'The --deviceType option can only be used alongside the --application option';
protected async validateOptions(options: FlagsDef) {
const { ExpectedError } = await import('../../errors');
if (options.device == null && options.fleet == null) {
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
if (options.device == null && options.application == null) {
throw new ExpectedError(this.missingDeviceOrAppMessage);
}
if (!options.fleet && options.deviceType) {
if (!options.application && options.deviceType) {
throw new ExpectedError(this.deviceTypeNotAllowedMessage);
}
const { validateDevOptionAndWarn } = await import('../../utils/config');
await validateDevOptionAndWarn(options.dev, options.version);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,7 +21,7 @@ import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type?: string;
type: string;
drive?: string;
help: void;
}
@ -32,18 +32,15 @@ interface ArgsDef {
export default class ConfigInjectCmd extends Command {
public static description = stripIndent`
Inject a config.json file to a balenaOS image or attached media.
Inject a configuration file into a device or OS image.
Inject a 'config.json' file to a balenaOS image file or attached SD card or
USB stick.
Documentation for the balenaOS 'config.json' file can be found at:
https://www.balena.io/docs/reference/OS/configuration/
Inject a config.json file to the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config inject my/config.json',
'$ balena config inject my/config.json --drive /dev/disk2',
'$ balena config inject my/config.json --type raspberrypi3',
'$ balena config inject my/config.json --type raspberrypi3 --drive /dev/disk2',
];
public static args = [
@ -57,24 +54,34 @@ export default class ConfigInjectCmd extends Command {
public static usage = 'config inject <file>';
public static flags: flags.Input<FlagsDef> = {
...cf.deviceTypeIgnored,
drive: cf.driveOrImg,
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigInjectCmd,
);
const { safeUmount } = await import('../../utils/umount');
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device/OS drive'));
await safeUmount(drive);
await umountAsync(drive);
const fs = await import('fs');
const configJSON = JSON.parse(
@ -82,7 +89,7 @@ export default class ConfigInjectCmd extends Command {
);
const config = await import('balena-config-json');
await config.write(drive, '', configJSON);
await config.write(drive, options.type, configJSON);
console.info('Done');
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,58 +21,58 @@ import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type?: string;
type: string;
drive?: string;
help: void;
json: boolean;
}
export default class ConfigReadCmd extends Command {
public static description = stripIndent`
Read the config.json file of a balenaOS image or attached media.
Read the configuration of a device or OS image.
Read the 'config.json' file of a balenaOS image file or attached SD card or
USB stick.
Documentation for the balenaOS 'config.json' file can be found at:
https://www.balena.io/docs/reference/OS/configuration/
Read the config.json file from the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config read',
'$ balena config read --drive /dev/disk2',
'$ balena config read --drive balena.img',
'$ balena config read --type raspberrypi3',
'$ balena config read --type raspberrypi3 --drive /dev/disk2',
];
public static usage = 'config read';
public static flags: flags.Input<FlagsDef> = {
...cf.deviceTypeIgnored,
drive: cf.driveOrImg,
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
json: cf.json,
};
public static authenticated = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReadCmd);
const { safeUmount } = await import('../../utils/umount');
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await safeUmount(drive);
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, '');
const configJSON = await config.read(drive, options.type);
if (options.json) {
console.log(JSON.stringify(configJSON, null, 4));
} else {
const prettyjson = await import('prettyjson');
console.log(prettyjson.render(configJSON));
}
const prettyjson = await import('prettyjson');
console.info(prettyjson.render(configJSON));
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,74 +21,63 @@ import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type?: string;
type: string;
drive?: string;
advanced: boolean;
help: void;
version?: string;
}
export default class ConfigReconfigureCmd extends Command {
public static description = stripIndent`
Interactively reconfigure a balenaOS image file or attached media.
Interactively reconfigure a device or OS image.
Interactively reconfigure a balenaOS image file or attached media.
This command extracts the device UUID from the 'config.json' file of the
chosen balenaOS image file or attached media, and then passes the UUID as
the '--device' argument to the 'balena os configure' command.
For finer-grained or scripted control of the operation, use the
'balena config read' and 'balena os configure' commands separately.
Interactively reconfigure a provisioned device or OS image.
`;
public static examples = [
'$ balena config reconfigure',
'$ balena config reconfigure --drive /dev/disk3',
'$ balena config reconfigure --drive balena.img --advanced',
'$ balena config reconfigure --type raspberrypi3',
'$ balena config reconfigure --type raspberrypi3 --advanced',
'$ balena config reconfigure --type raspberrypi3 --drive /dev/disk2',
];
public static usage = 'config reconfigure';
public static flags: flags.Input<FlagsDef> = {
...cf.deviceTypeIgnored,
drive: cf.driveOrImg,
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
advanced: flags.boolean({
description: 'show advanced commands',
char: 'v',
}),
help: cf.help,
version: flags.string({
description: 'balenaOS version, for example "2.32.0" or "2.44.0+rev1"',
}),
};
public static authenticated = true;
public static root = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReconfigureCmd);
const { safeUmount } = await import('../../utils/umount');
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await safeUmount(drive);
await umountAsync(drive);
const config = await import('balena-config-json');
const { uuid } = await config.read(drive, '');
await safeUmount(drive);
if (!uuid) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(
`Error: UUID not found in 'config.json' file for '${drive}'`,
);
}
const { uuid } = await config.read(drive, options.type);
await umountAsync(drive);
const configureCommand = ['os', 'configure', drive, '--device', uuid];
if (options.version) {
configureCommand.push('--version', options.version);
}
if (options.advanced) {
configureCommand.push('--advanced');
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,7 +21,7 @@ import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type?: string;
type: string;
drive?: string;
help: void;
}
@ -33,19 +33,16 @@ interface ArgsDef {
export default class ConfigWriteCmd extends Command {
public static description = stripIndent`
Write a key-value pair to the config.json file of an OS image or attached media.
Write a key-value pair to configuration of a device or OS image.
Write a key-value pair to the 'config.json' file of a balenaOS image file or
attached SD card or USB stick.
Documentation for the balenaOS 'config.json' file can be found at:
https://www.balena.io/docs/reference/OS/configuration/
Write a key-value pair to the config.json file on the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config write ntpServers "0.resinio.pool.ntp.org 1.resinio.pool.ntp.org"',
'$ balena config write --drive /dev/disk2 hostname custom-hostname',
'$ balena config write --drive balena.img os.network.connectivity.interval 300',
'$ balena config write --type raspberrypi3 username johndoe',
'$ balena config write --type raspberrypi3 --drive /dev/disk2 username johndoe',
'$ balena config write --type raspberrypi3 files.network/settings "..."',
];
public static args = [
@ -64,46 +61,46 @@ export default class ConfigWriteCmd extends Command {
public static usage = 'config write <key> <value>';
public static flags: flags.Input<FlagsDef> = {
...cf.deviceTypeIgnored,
drive: cf.driveOrImg,
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigWriteCmd,
);
const { denyMount, safeUmount } = await import('../../utils/umount');
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await safeUmount(drive);
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, '');
const configJSON = await config.read(drive, options.type);
console.info(`Setting ${params.key} to ${params.value}`);
ConfigWriteCmd.updateConfigJson(configJSON, params.key, params.value);
const _ = await import('lodash');
_.set(configJSON, params.key, params.value);
await denyMount(drive, async () => {
await safeUmount(drive);
await config.write(drive, '', configJSON);
});
await umountAsync(drive);
await config.write(drive, options.type, configJSON);
console.info('Done');
}
/** Call Lodash's _.setWith(). Moved here for ease of testing. */
static updateConfigJson(configJSON: object, key: string, value: string) {
const _ = require('lodash') as typeof import('lodash');
// note: _.setWith() is needed instead of _.set() because, given a key
// like `os.udevRules.101`, _.set() creates a udevRules array (rather
// than a dictionary) and sets the 101st array element to value, while
// we actually want udevRules to be dictionary like { '101': value }
_.setWith(configJSON, key, value, (v) => v || {});
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,35 +20,22 @@ import type { ImageDescriptor } from 'resin-compose-parse';
import Command from '../command';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk, stripIndent } from '../utils/lazy';
import {
dockerignoreHelp,
registrySecretsHelp,
buildArgDeprecation,
} from '../utils/messages';
import * as ca from '../utils/common-args';
import { getBalenaSdk, getChalk } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import * as compose from '../utils/compose';
import type {
BuiltImage,
ComposeCliFlags,
ComposeOpts,
Release as ComposeReleaseInfo,
} from '../utils/compose-types';
import type { BuildOpts, DockerCliFlags } from '../utils/docker';
import type { DockerCliFlags } from '../utils/docker';
import {
applyReleaseTagKeysAndValues,
buildProject,
composeCliFlags,
isBuildConfig,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
import { dockerCliFlags } from '../utils/docker';
import type {
Application,
ApplicationType,
DeviceType,
Release,
} from 'balena-sdk';
import type { Application, ApplicationType, DeviceType } from 'balena-sdk';
interface ApplicationWithArch extends Application {
arch: string;
@ -58,24 +45,22 @@ interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string;
build: boolean;
nologupload: boolean;
'release-tag'?: string[];
draft: boolean;
help: void;
}
interface ArgsDef {
fleet: string;
appName: string;
image?: string;
}
export default class DeployCmd extends Command {
public static description = `\
Deploy a single image or a multicontainer project to a balena fleet.
Deploy a single image or a multicontainer project to a balena application.
Usage: \`deploy <fleet> ([image] | --build [--source build-dir])\`
Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
Use this command to deploy an image or a complete multicontainer project to a
fleet, optionally building it first. The source images are searched for
Use this command to deploy an image or a complete multicontainer project to an
application, optionally building it first. The source images are searched for
(and optionally built) using the docker daemon in your development machine or
balena device. (See also the \`balena push\` command for the option of building
the image in the balenaCloud build servers.)
@ -83,15 +68,13 @@ the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a docker-compose.yml file. If one is
found, this command will deploy each service defined in the compose file,
building it first if an image for it doesn't exist. Image names will be looked
up according to the scheme: \`<projectName>_<serviceName>\`.
building it first if an image for it doesn't exist. If a compose file isn't
found, the command will look for a Dockerfile[.template] file (or alternative
Dockerfile specified with the \`-f\` option), and if yet that isn't found, it
will try to generate one.
If a compose file isn't found, the command will look for a Dockerfile[.template]
file (or alternative Dockerfile specified with the \`-f\` option), and if yet
that isn't found, it will try to generate one.
To deploy to a fleet where you are a collaborator, use fleet slug including the
organization: \`balena deploy <organization>/<fleet>\`.
To deploy to an app on which you're a collaborator, use
\`balena deploy <appOwnerUsername>/<appName>\`.
${registrySecretsHelp}
@ -99,22 +82,25 @@ ${dockerignoreHelp}
`;
public static examples = [
'$ balena deploy myFleet',
'$ balena deploy myorg/myfleet --build --source myBuildDir/',
'$ balena deploy myorg/myfleet myRepo/myImage',
'$ balena deploy myFleet myRepo/myImage --release-tag key1 "" key2 "value2 with spaces"',
'$ balena deploy myApp',
'$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myApp myApp/myImage',
];
public static args = [
ca.fleetRequired,
{
name: 'appName',
description: 'the name of the application to deploy to',
required: true,
},
{
name: 'image',
description: 'the image to deploy',
},
];
public static usage = 'deploy <fleet> [image]';
// TODO: docker-compose naming
public static usage = 'deploy <appName> [image]';
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description:
@ -129,22 +115,6 @@ ${dockerignoreHelp}
description:
"don't upload build logs to the dashboard with image (if building)",
}),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the image deployment is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
}),
draft: flags.boolean({
description: stripIndent`
Deploy the release as a draft. Draft releases are ignored
by the 'track latest' release policy but can be used through release pinning.
Draft releases can be marked as final through the API. Releases are created
as final by default unless this option is given.`,
default: false,
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
@ -161,17 +131,14 @@ ${dockerignoreHelp}
DeployCmd,
);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const logger = await Command.getLogger();
logger.logDebug('Parsing input...');
const { fleet, image } = params;
// Build args are under consideration for removal - warn user
if (options.buildArg) {
console.log(buildArgDeprecation);
}
const { appName, image } = params;
if (image != null && options.build) {
throw new ExpectedError(
@ -184,29 +151,27 @@ ${dockerignoreHelp}
'../utils/compose_ts'
);
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
if (image) {
options['registry-secrets'] = await getRegistrySecrets(
sdk,
options['registry-secrets'],
);
} else {
const { dockerfilePath, registrySecrets } =
await validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
const {
dockerfilePath,
registrySecrets,
} = await validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
options.dockerfile = dockerfilePath;
options['registry-secrets'] = registrySecrets;
}
const helpers = await import('../utils/helpers');
const app = await helpers.getAppWithArch(fleet);
const app = await helpers.getAppWithArch(appName);
const dockerUtils = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([
@ -215,26 +180,19 @@ ${dockerignoreHelp}
compose.generateOpts(options),
]);
const release = await this.deployProject(docker, logger, composeOpts, {
await this.deployProject(docker, logger, composeOpts, {
app,
appName: fleet, // may be prefixed by 'owner/', unlike app.app_name
appName, // may be prefixed by 'owner/', unlike app.app_name
image,
shouldPerformBuild: !!options.build,
shouldUploadLogs: !options.nologupload,
buildEmulated: !!options.emulated,
createAsDraft: options.draft,
buildOpts,
});
await applyReleaseTagKeysAndValues(
sdk,
release.id,
releaseTagKeys,
releaseTagValues,
);
}
async deployProject(
docker: import('dockerode'),
docker: import('docker-toolbelt'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
@ -245,8 +203,7 @@ ${dockerignoreHelp}
shouldPerformBuild: boolean;
shouldUploadLogs: boolean;
buildEmulated: boolean;
buildOpts: BuildOpts;
createAsDraft: boolean;
buildOpts: any; // arguments to forward to docker build command
},
) {
const _ = await import('lodash');
@ -259,15 +216,10 @@ ${dockerignoreHelp}
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
try {
const project = await loadProject(
logger,
composeOpts,
opts.image,
opts.buildOpts.t,
);
const project = await loadProject(logger, composeOpts, opts.image);
if (project.descriptors.length > 1 && !appType?.supports_multicontainer) {
throw new ExpectedError(
'Target fleet does not support multiple containers. Aborting!',
'Target application does not support multiple containers. Aborting!',
);
}
@ -319,6 +271,7 @@ ${dockerignoreHelp}
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
builtImagesByService = _.keyBy(builtImages, 'serviceName');
@ -333,12 +286,12 @@ ${dockerignoreHelp}
},
);
let release: Release | ComposeReleaseInfo['release'];
let release;
if (appType?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy');
const msg = getChalk().yellow(
'Target fleet requires legacy deploy method.',
'Target application requires legacy deploy method.',
);
logger.logWarn(msg);
@ -382,8 +335,6 @@ ${dockerignoreHelp}
`Bearer ${auth}`,
apiEndpoint,
!opts.shouldUploadLogs,
composeOpts.projectPath,
opts.createAsDraft,
);
}
@ -393,7 +344,6 @@ ${dockerignoreHelp}
console.log();
console.log(doodles.getDoodle()); // Show charlie
console.log();
return release;
} catch (err) {
logger.logError('Deploy failed');
throw err;

View File

@ -22,12 +22,11 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Application, Release } from 'balena-sdk';
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
fleet: string; // 'org/name' slug
application_name?: string;
device_type?: string;
commit?: string;
last_seen?: string;
@ -97,7 +96,6 @@ export default class DeviceCmd extends Command {
'os_version',
'memory_usage',
'memory_total',
'public_address',
'storage_block_device',
'storage_usage',
'storage_total',
@ -112,10 +110,9 @@ export default class DeviceCmd extends Command {
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
const belongsToApplication =
device.belongs_to__application as Application[];
device.fleet = belongsToApplication?.[0]
? belongsToApplication[0].slug
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]
? belongsToApplication[0].app_name
: 'N/a';
device.device_type = device.is_of__device_type[0].slug;
@ -171,9 +168,8 @@ export default class DeviceCmd extends Command {
'status',
'is_online',
'ip_address',
'public_address',
'mac_address',
'fleet',
'application_name',
'last_seen',
'uuid',
'commit',

View File

@ -23,59 +23,40 @@ import { applicationIdInfo } from '../../utils/messages';
import { runCommand } from '../../utils/helpers';
interface FlagsDef {
fleet?: string;
application?: string;
app?: string;
yes: boolean;
advanced: boolean;
'os-version'?: string;
drive?: string;
config?: string;
help: void;
'provisioning-key-name'?: string;
}
export default class DeviceInitCmd extends Command {
public static description = stripIndent`
Initialize a device with balenaOS.
Register a new device in the selected fleet, download the OS image for the
fleet's default device type, configure the image and write it to an SD card.
This command effectively combines several other balena CLI commands in one,
namely:
Initialize a device by downloading the OS image of a certain application
and writing it to an SD Card.
'balena device register'
'balena os download'
'balena os build-config' or 'balena config generate'
'balena os configure'
'balena os local flash'
Possible arguments for the '--fleet', '--os-version' and '--drive' options can
be listed respectively with the commands:
'balena fleets'
'balena os versions'
'balena util available-drives'
If the '--fleet' or '--drive' options are omitted, interactive menus will be
presented with values to choose from. If the '--os-version' option is omitted,
the latest released OS version for the fleet's default device type will be used.
Note, if the application option is omitted it will be prompted
for interactively.
${applicationIdInfo.split('\n').join('\n\t\t')}
Image configuration questions will be asked interactively unless a pre-configured
'config.json' file is provided with the '--config' option. The file can be
generated with the 'balena config generate' or 'balena os build-config' commands.
`;
public static examples = [
'$ balena device init',
'$ balena device init -f myorg/myfleet',
'$ balena device init --fleet myFleet --os-version 2.83.21+rev1.prod --drive /dev/disk5 --config config.json --yes',
'$ balena device init --application MyApp',
'$ balena device init -a myorg/myapp',
];
public static usage = 'device init';
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
application: cf.application,
app: cf.app,
yes: cf.yes,
advanced: flags.boolean({
char: 'v',
@ -94,9 +75,6 @@ export default class DeviceInitCmd extends Command {
config: flags.string({
description: 'path to the config JSON file, see `balena os build-config`',
}),
'provisioning-key-name': flags.string({
description: 'custom key name assigned to generated provisioning api key',
}),
help: cf.help,
};
@ -117,13 +95,15 @@ export default class DeviceInitCmd extends Command {
const logger = await Command.getLogger();
const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
// Get application and
const application = (await getApplication(
balena,
options.fleet ||
(
await (await import('../../utils/patterns')).selectApplication()
).id,
options['application'] ||
(await (await import('../../utils/patterns')).selectApplication()).id,
{
$expand: {
is_for__device_type: {
@ -135,7 +115,7 @@ export default class DeviceInitCmd extends Command {
// Register new device
const deviceUuid = balena.models.device.generateUniqueKey();
console.info(`Registering to ${application.slug}: ${deviceUuid}`);
console.info(`Registering to ${application.app_name}: ${deviceUuid}`);
await balena.models.device.register(application.id, deviceUuid);
const device = await balena.models.device.get(deviceUuid);
@ -178,13 +158,6 @@ export default class DeviceInitCmd extends Command {
} else if (options.advanced) {
configureCommand.push('--advanced');
}
if (options['provisioning-key-name']) {
configureCommand.push(
'--provisioning-key-name',
options['provisioning-key-name'],
);
}
await runCommand(configureCommand);
}

View File

@ -1,114 +0,0 @@
/**
* @license
* Copyright 2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
enable: boolean;
disable: boolean;
status: boolean;
help?: void;
}
interface ArgsDef {
uuid: string | number;
}
export default class DeviceLocalModeCmd extends Command {
public static description = stripIndent`
Get or manage the local mode status for a device.
Output current local mode status, or enable/disable local mode
for specified device.
`;
public static examples = [
'$ balena device local-mode 23c73a1',
'$ balena device local-mode 23c73a1 --enable',
'$ balena device local-mode 23c73a1 --disable',
'$ balena device local-mode 23c73a1 --status',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'the uuid of the device to manage',
parse: (dev) => tryAsInteger(dev),
required: true,
},
];
public static usage = 'device local-mode <uuid>';
public static flags: flags.Input<FlagsDef> = {
enable: flags.boolean({
description: 'enable local mode',
exclusive: ['disable', 'status'],
}),
disable: flags.boolean({
description: 'disable local mode',
exclusive: ['enable', 'status'],
}),
status: flags.boolean({
description: 'output boolean indicating local mode status',
exclusive: ['enable', 'disable'],
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceLocalModeCmd,
);
const balena = getBalenaSdk();
if (options.enable) {
await balena.models.device.enableLocalMode(params.uuid);
console.log(`Local mode on device ${params.uuid} is now ENABLED.`);
} else if (options.disable) {
await balena.models.device.disableLocalMode(params.uuid);
console.log(`Local mode on device ${params.uuid} is now DISABLED.`);
} else if (options.status) {
// Output bool indicating local mode status
const isEnabled = await balena.models.device.isInLocalMode(params.uuid);
console.log(isEnabled);
} else {
// If no flag provided, output status and tip
const isEnabled = await balena.models.device.isInLocalMode(params.uuid);
console.log(
`Local mode on device ${params.uuid} is ${
isEnabled ? 'ENABLED' : 'DISABLED'
}.`,
);
if (isEnabled) {
console.log('To disable, use:');
console.log(` balena device local-mode ${params.uuid} --disable`);
} else {
console.log('To enable, use:');
console.log(` balena device local-mode ${params.uuid} --enable`);
}
}
}
}

View File

@ -15,29 +15,22 @@
* limitations under the License.
*/
import type { flags } from '@oclif/command';
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import type {
BalenaSDK,
Device,
DeviceType,
PineTypedResult,
} from 'balena-sdk';
import type { Application, BalenaSDK } from 'balena-sdk';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { ExpectedError } from '../../errors';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
import { ExpectedError } from '../../errors';
type ExtendedDevice = PineTypedResult<
Device,
typeof import('../../utils/helpers').expandForAppNameAndCpuArch
> & {
interface ExtendedDevice extends DeviceWithDeviceType {
application_name?: string;
};
}
interface FlagsDef {
fleet?: string;
application?: string;
app?: string;
help: void;
}
@ -47,11 +40,12 @@ interface ArgsDef {
export default class DeviceMoveCmd extends Command {
public static description = stripIndent`
Move one or more devices to another fleet.
Move one or more devices to another application.
Move one or more devices to another fleet.
Move one or more devices to another application.
If --fleet is omitted, the fleet will be prompted for interactively.
Note, if the application option is omitted it will be prompted
for interactively.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
@ -59,8 +53,8 @@ export default class DeviceMoveCmd extends Command {
public static examples = [
'$ balena device move 7cf02a6',
'$ balena device move 7cf02a6,dc39e52',
'$ balena device move 7cf02a6 --fleet MyNewFleet',
'$ balena device move 7cf02a6 -f myorg/mynewfleet',
'$ balena device move 7cf02a6 --application MyNewApp',
'$ balena device move 7cf02a6 -a myorg/mynewapp',
];
public static args: Array<IArg<any>> = [
@ -75,7 +69,8 @@ export default class DeviceMoveCmd extends Command {
public static usage = 'device move <uuid(s)>';
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
application: cf.application,
app: cf.app,
help: cf.help,
};
@ -89,7 +84,10 @@ export default class DeviceMoveCmd extends Command {
const balena = getBalenaSdk();
const { tryAsInteger } = await import('../../utils/validation');
const { expandForAppNameAndCpuArch } = await import('../../utils/helpers');
const { expandForAppName } = await import('../../utils/helpers');
options.application = options.application || options.app;
delete options.app;
// Parse ids string into array of correct types
const deviceIds: Array<string | number> = params.uuid
@ -100,16 +98,15 @@ export default class DeviceMoveCmd extends Command {
const devices = await Promise.all(
deviceIds.map(
(uuid) =>
balena.models.device.get(
uuid,
expandForAppNameAndCpuArch,
) as Promise<ExtendedDevice>,
balena.models.device.get(uuid, expandForAppName) as Promise<
ExtendedDevice
>,
),
);
// Map application name for each device
for (const device of devices) {
const belongsToApplication = device.belongs_to__application;
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]
? belongsToApplication[0].app_name
: 'N/a';
@ -119,15 +116,17 @@ export default class DeviceMoveCmd extends Command {
const { getApplication } = await import('../../utils/sdk');
// Get destination application
const application = options.fleet
? await getApplication(balena, options.fleet)
const application = options.application
? await getApplication(balena, options.application)
: await this.interactivelySelectApplication(balena, devices);
// Move each device
for (const uuid of deviceIds) {
try {
await balena.models.device.move(uuid, application.id);
console.info(`Device ${uuid} was moved to fleet ${application.slug}`);
console.info(
`Device ${uuid} was moved to application ${application.slug}`,
);
} catch (err) {
console.info(`${err.message}, uuid: ${uuid}`);
process.exitCode = 1;
@ -139,56 +138,43 @@ export default class DeviceMoveCmd extends Command {
balena: BalenaSDK,
devices: ExtendedDevice[],
) {
const { getExpandedProp } = await import('../../utils/pine');
// deduplicate the slugs
const deviceCpuArchs = Array.from(
new Set(
devices.map(
(d) => d.is_of__device_type[0].is_of__cpu_architecture[0].slug,
const [deviceDeviceTypes, deviceTypes] = await Promise.all([
Promise.all(
devices.map((device) =>
balena.models.device.getManifestBySlug(
device.is_of__device_type[0].slug,
),
),
),
);
balena.models.config.getDeviceTypes(),
]);
const deviceTypeOptions = {
$select: 'slug',
$expand: {
is_of__cpu_architecture: {
$select: 'slug',
},
},
} as const;
const deviceTypes = (await balena.models.deviceType.getAllSupported(
deviceTypeOptions,
)) as Array<PineTypedResult<DeviceType, typeof deviceTypeOptions>>;
const compatibleDeviceTypeSlugs = new Set(
deviceTypes
.filter((deviceType) => {
const deviceTypeArch = getExpandedProp(
deviceType.is_of__cpu_architecture,
'slug',
)!;
return deviceCpuArchs.every((deviceCpuArch) =>
balena.models.os.isArchitectureCompatibleWith(
deviceCpuArch,
deviceTypeArch,
),
);
})
.map((deviceType) => deviceType.slug),
const compatibleDeviceTypes = deviceTypes.filter((dt) =>
deviceDeviceTypes.every(
(deviceDeviceType) =>
balena.models.os.isArchitectureCompatibleWith(
deviceDeviceType.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceDeviceType.isDependent &&
dt.state !== 'DISCONTINUED',
),
);
const patterns = await import('../../utils/patterns');
try {
const application = await patterns.selectApplication(
(app) =>
compatibleDeviceTypeSlugs.has(app.is_for__device_type[0].slug) &&
compatibleDeviceTypes.some(
(dt) => dt.slug === app.is_for__device_type[0].slug,
) &&
// @ts-ignore using the extended device object prop
devices.some((device) => device.application_name !== app.app_name),
true,
);
return application;
} catch (err) {
if (!compatibleDeviceTypeSlugs.size) {
if (deviceDeviceTypes.length) {
throw new ExpectedError(
`${err.message}\nDo all devices have a compatible architecture?`,
);

View File

@ -79,15 +79,19 @@ export default class DeviceOsUpdateCmd extends Command {
const sdk = getBalenaSdk();
// Get device info
const { uuid, is_of__device_type, os_version, os_variant } =
(await sdk.models.device.get(params.uuid, {
$select: ['uuid', 'os_version', 'os_variant'],
$expand: {
is_of__device_type: {
$select: 'slug',
},
const {
uuid,
is_of__device_type,
os_version,
os_variant,
} = (await sdk.models.device.get(params.uuid, {
$select: ['uuid', 'os_version', 'os_variant'],
$expand: {
is_of__device_type: {
$select: 'slug',
},
})) as DeviceWithDeviceType;
},
})) as DeviceWithDeviceType;
// Get current device OS version
const currentOsVersion = sdk.models.device.getOsVersion({

View File

@ -31,10 +31,10 @@ interface ArgsDef {
export default class DevicePurgeCmd extends Command {
public static description = stripIndent`
Purge data from a device.
Purge application data from a device.
Purge data from a device.
This will clear the device's "/data" directory.
Purge application data from a device.
This will clear the application's /data directory.
Multiple devices may be specified with a comma-separated list
of values (no spaces).

View File

@ -29,29 +29,27 @@ interface FlagsDef {
}
interface ArgsDef {
fleet: string;
application: string;
}
export default class DeviceRegisterCmd extends Command {
public static description = stripIndent`
Register a new device.
Register a device.
Register a new device with a balena fleet.
If --uuid is not provided, a new UUID will be automatically assigned.
Register a device to an application.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena device register MyFleet',
'$ balena device register MyFleet --uuid <uuid>',
'$ balena device register myorg/myfleet --uuid <uuid>',
'$ balena device register MyApp',
'$ balena device register MyApp --uuid <uuid>',
'$ balena device register myorg/myapp --uuid <uuid>',
];
public static args: Array<IArg<any>> = [ca.fleetRequired];
public static args: Array<IArg<any>> = [ca.applicationRequired];
public static usage = 'device register <fleet>';
public static usage = 'device register <application>';
public static flags: flags.Input<FlagsDef> = {
uuid: flags.string({
@ -72,10 +70,10 @@ export default class DeviceRegisterCmd extends Command {
const balena = getBalenaSdk();
const application = await getApplication(balena, params.fleet);
const application = await getApplication(balena, params.application);
const uuid = options.uuid ?? balena.models.device.generateUniqueKey();
console.info(`Registering to ${application.slug}: ${uuid}`);
console.info(`Registering to ${application.app_name}: ${uuid}`);
const result = await balena.models.device.register(application.id, uuid);

View File

@ -21,17 +21,17 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { applicationIdInfo, jsonInfo } from '../../utils/messages';
import type { Application } from 'balena-sdk';
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
fleet?: string | null; // 'org/name' slug
application_name?: string | null;
device_type?: string | null;
}
interface FlagsDef {
fleet?: string;
application?: string;
app?: string;
help: void;
json: boolean;
}
@ -40,9 +40,9 @@ export default class DevicesCmd extends Command {
public static description = stripIndent`
List all devices.
List all of your devices.
list all devices that belong to you.
Devices can be filtered by fleet with the \`--fleet\` option.
You can filter the devices by application by using the \`--application\` option.
${applicationIdInfo.split('\n').join('\n\t\t')}
@ -50,14 +50,17 @@ export default class DevicesCmd extends Command {
`;
public static examples = [
'$ balena devices',
'$ balena devices --fleet MyFleet',
'$ balena devices -f myorg/myfleet',
'$ balena devices --application MyApp',
'$ balena devices --app MyApp',
'$ balena devices -a MyApp',
'$ balena devices -a myorg/myapp',
];
public static usage = 'devices';
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
application: cf.application,
app: cf.app,
json: cf.json,
help: cf.help,
};
@ -71,11 +74,15 @@ export default class DevicesCmd extends Command {
const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
let devices;
if (options.fleet != null) {
if (options.application != null) {
const { getApplication } = await import('../../utils/sdk');
const application = await getApplication(balena, options.fleet);
const application = await getApplication(balena, options.application);
devices = (await balena.models.device.getAllByApplication(
application.id,
expandForAppName,
@ -89,9 +96,8 @@ export default class DevicesCmd extends Command {
devices = devices.map(function (device) {
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
const belongsToApplication =
device.belongs_to__application as Application[];
device.fleet = belongsToApplication?.[0]?.slug || null;
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]?.app_name || null;
device.uuid = options.json ? device.uuid : device.uuid.slice(0, 7);
@ -104,20 +110,23 @@ export default class DevicesCmd extends Command {
'uuid',
'device_name',
'device_type',
'fleet',
'application_name',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
const _ = await import('lodash');
if (options.json) {
const { pickAndRename } = await import('../../utils/helpers');
const mapped = devices.map((device) => pickAndRename(device, fields));
console.log(JSON.stringify(mapped, null, 4));
console.log(
JSON.stringify(
devices.map((device) => _.pick(device, fields)),
null,
4,
),
);
} else {
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type * as SDK from 'balena-sdk';
import * as _ from 'lodash';
import Command from '../../command';
@ -23,8 +24,10 @@ import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
discontinued: boolean;
help: void;
json?: boolean;
verbose?: boolean;
}
export default class DevicesSupportedCmd extends Command {
@ -33,6 +36,11 @@ export default class DevicesSupportedCmd extends Command {
List the supported device types (like 'raspberrypi3' or 'intel-nuc').
The --verbose option adds extra columns/fields to the output, including the
"STATE" column whose values are one of 'new', 'released' or 'discontinued'.
However, 'discontinued' device types are only listed if the '--discontinued'
option is used.
The --json option is recommended when scripting the output of this command,
because the JSON format is less likely to change and it better represents data
types like lists and empty strings (for example, the ALIASES column contains a
@ -41,7 +49,8 @@ export default class DevicesSupportedCmd extends Command {
`;
public static examples = [
'$ balena devices supported',
'$ balena devices supported --json',
'$ balena devices supported --verbose',
'$ balena devices supported -vj',
];
public static usage = (
@ -50,47 +59,55 @@ export default class DevicesSupportedCmd extends Command {
).trim();
public static flags: flags.Input<FlagsDef> = {
discontinued: flags.boolean({
description: 'include "discontinued" device types',
}),
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
verbose: flags.boolean({
char: 'v',
description:
'add extra columns in the tabular output (ALIASES, ARCH, STATE)',
}),
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(DevicesSupportedCmd);
const [dts, configDTs] = await Promise.all([
getBalenaSdk().models.deviceType.getAllSupported({
$expand: { is_of__cpu_architecture: { $select: 'slug' } },
$select: ['slug', 'name'],
const dts = await getBalenaSdk().models.config.getDeviceTypes();
let deviceTypes: Array<Partial<SDK.DeviceTypeJson.DeviceType>> = dts.map(
(d) => {
if (d.aliases && d.aliases.length) {
// remove aliases that are equal to the slug
d.aliases = d.aliases.filter((alias: string) => alias !== d.slug);
if (!options.json) {
// stringify the aliases array with commas and spaces
d.aliases = [d.aliases.join(', ')];
}
} else {
// ensure it is always an array (for the benefit of JSON output)
d.aliases = [];
}
return d;
},
);
if (!options.discontinued) {
deviceTypes = deviceTypes.filter((dt) => dt.state !== 'DISCONTINUED');
}
const fields = options.verbose
? ['slug', 'aliases', 'arch', 'state', 'name']
: ['slug', 'aliases', 'arch', 'name'];
deviceTypes = _.sortBy(
deviceTypes.map((d) => {
const picked = _.pick(d, fields);
// 'BETA' renamed to 'NEW'
picked.state = picked.state === 'BETA' ? 'NEW' : picked.state;
return picked;
}),
getBalenaSdk().models.config.getDeviceTypes(),
]);
const dtsBySlug = _.keyBy(dts, (dt) => dt.slug);
const configDTsBySlug = _.keyBy(configDTs, (dt) => dt.slug);
interface DT {
slug: string;
aliases: string[];
arch: string;
name: string;
}
let deviceTypes: DT[] = [];
for (const slug of Object.keys(dtsBySlug)) {
const configDT: Partial<typeof configDTs[0]> =
configDTsBySlug[slug] || {};
const aliases = (configDT.aliases || []).filter(
(alias) => alias !== slug,
);
const dt: Partial<typeof dts[0]> = dtsBySlug[slug] || {};
deviceTypes.push({
slug,
aliases: options.json ? aliases : [aliases.join(', ')],
arch: (dt.is_of__cpu_architecture as any)?.[0]?.slug || 'n/a',
name: dt.name || 'N/A',
});
}
const fields = ['slug', 'aliases', 'arch', 'name'];
deviceTypes = _.sortBy(deviceTypes, fields);
fields,
);
if (options.json) {
console.log(JSON.stringify(deviceTypes, null, 4));
} else {

View File

@ -24,7 +24,7 @@ import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
fleet?: string;
application?: string;
device?: string; // device UUID
help: void;
quiet: boolean;
@ -38,17 +38,18 @@ interface ArgsDef {
export default class EnvAddCmd extends Command {
public static description = stripIndent`
Add env or config variable to fleets, devices or services.
Add env or config variable to application(s), device(s) or service(s).
Add an environment or config variable to one or more fleets, devices or
services, as selected by the respective command-line options. Either the
--fleet or the --device option must be provided, and either may be be
Add an environment or config variable to one or more applications, devices
or services, as selected by the respective command-line options. Either the
--application or the --device option must be provided, and either may be be
used alongside the --service option to define a service-specific variable.
(A service corresponds to a Docker image/container in a microservices fleet.)
(A service is an application container in a "microservices" application.)
When the --service option is used in conjunction with the --device option,
the service variable applies to the selected device only. Otherwise, it
applies to all devices of the selected fleet. If the --service option is
omitted, the variable applies to all services.
the service variable applies to the selected device only. Otherwise, it
applies to all devices of the selected application (i.e., the application's
fleet). If the --service option is omitted, the variable applies to all
services.
If VALUE is omitted, the CLI will attempt to use the value of the environment
variable of same name in the CLI process' environment. In this case, a warning
@ -60,18 +61,19 @@ export default class EnvAddCmd extends Command {
running on devices. They are also stored differently in the balenaCloud API
database. Configuration variables cannot be set for specific services,
therefore the --service option cannot be used when the variable name starts
with a reserved prefix. When defining custom fleet variables, please avoid
these reserved prefixes.
with a reserved prefix. When defining custom application variables, please
avoid the reserved prefixes.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena env add TERM --fleet MyFleet',
'$ balena env add EDITOR vim -f myorg/myfleet',
'$ balena env add EDITOR vim --fleet MyFleet,MyFleet2',
'$ balena env add EDITOR vim --fleet MyFleet --service MyService',
'$ balena env add EDITOR vim --fleet MyFleet,MyFleet2 --service MyService,MyService2',
'$ balena env add TERM --application MyApp',
'$ balena env add EDITOR vim --application MyApp',
'$ balena env add EDITOR vim -a myorg/myapp',
'$ balena env add EDITOR vim --application MyApp,MyApp2',
'$ balena env add EDITOR vim --application MyApp --service MyService',
'$ balena env add EDITOR vim --application MyApp,MyApp2 --service MyService,MyService2',
'$ balena env add EDITOR vim --device 7cf02a6',
'$ balena env add EDITOR vim --device 7cf02a6,d6f1433',
'$ balena env add EDITOR vim --device 7cf02a6 --service MyService',
@ -95,8 +97,8 @@ export default class EnvAddCmd extends Command {
public static usage = 'env add <name> [value]';
public static flags: flags.Input<FlagsDef> = {
fleet: { ...cf.fleet, exclusive: ['device'] },
device: { ...cf.device, exclusive: ['fleet'] },
application: { ...cf.application, exclusive: ['device'] },
device: { ...cf.device, exclusive: ['application'] },
help: cf.help,
quiet: cf.quiet,
service: cf.service,
@ -108,9 +110,9 @@ export default class EnvAddCmd extends Command {
);
const cmd = this;
if (!options.fleet && !options.device) {
if (!options.application && !options.device) {
throw new ExpectedError(
'Either the --fleet or the --device option must be specified',
'Either the --application or the --device option must be specified',
);
}
@ -150,17 +152,16 @@ export default class EnvAddCmd extends Command {
}
const varType = isConfigVar ? 'configVar' : 'envVar';
if (options.fleet) {
const { getFleetSlug } = await import('../../utils/sdk');
for (const app of options.fleet.split(',')) {
if (options.application) {
for (const app of options.application.split(',')) {
try {
await balena.models.application[varType].set(
await getFleetSlug(balena, app),
app,
params.name,
params.value,
);
} catch (err) {
console.error(`${err.message}, fleet: ${app}`);
console.error(`${err.message}, app: ${app}`);
process.exitCode = 1;
}
}
@ -182,15 +183,15 @@ export default class EnvAddCmd extends Command {
}
/**
* Add service variables for a device or fleet.
* Add service variables for a device or application.
*/
async function setServiceVars(
sdk: BalenaSdk.BalenaSDK,
params: ArgsDef,
options: FlagsDef,
) {
if (options.fleet) {
for (const app of options.fleet.split(',')) {
if (options.application) {
for (const app of options.application.split(',')) {
for (const service of options.service!.split(',')) {
try {
const serviceId = await getServiceIdForApp(sdk, app, service);
@ -200,7 +201,7 @@ async function setServiceVars(
params.value!,
);
} catch (err) {
console.error(`${err.message}, fleet: ${app}`);
console.error(`${err.message}, application: ${app}`);
process.exitCode = 1;
}
}
@ -215,7 +216,7 @@ async function setServiceVars(
sdk,
uuid,
['id'],
['slug'],
['app_name'],
);
} catch (err) {
console.error(`${err.message}, device: ${uuid}`);
@ -224,7 +225,11 @@ async function setServiceVars(
}
for (const service of options.service!.split(',')) {
try {
const serviceId = await getServiceIdForApp(sdk, app.slug, service);
const serviceId = await getServiceIdForApp(
sdk,
app.app_name,
service,
);
await sdk.models.device.serviceVar.set(
device.id,
serviceId,
@ -257,7 +262,7 @@ async function getServiceIdForApp(
}
if (serviceId === undefined) {
throw new ExpectedError(
`Cannot find service ${serviceName} for fleet ${appName}`,
`Cannot find service ${serviceName} for application ${appName}`,
);
}
return serviceId;

View File

@ -38,9 +38,9 @@ interface ArgsDef {
export default class EnvRenameCmd extends Command {
public static description = stripIndent`
Change the value of a config or env var for a fleet, device or service.
Change the value of a config or env var for an app, device or service.
Change the value of a configuration or environment variable for a fleet,
Change the value of a configuration or environment variable for an application,
device or service, as selected by command-line options.
${ec.rmRenameHelp.split('\n').join('\n\t\t')}

View File

@ -37,9 +37,9 @@ interface ArgsDef {
export default class EnvRmCmd extends Command {
public static description = stripIndent`
Remove a config or env var from a fleet, device or service.
Remove a config or env var from an application, device or service.
Remove a configuration or environment variable from a fleet, device
Remove a configuration or environment variable from an application, device
or service, as selected by command-line options.
${ec.rmRenameHelp.split('\n').join('\n\t\t')}
@ -91,6 +91,8 @@ export default class EnvRmCmd extends Command {
await confirm(
opt.yes || false,
'Are you sure you want to delete the environment variable?',
undefined,
true,
);
const balena = getBalenaSdk();

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -22,52 +22,54 @@ import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
import { isV13 } from '../utils/version';
interface FlagsDef {
fleet?: string;
application?: string;
config: boolean;
device?: string; // device UUID
json: boolean;
help: void;
service?: string; // service name
verbose: boolean;
}
interface EnvironmentVariableInfo extends SDK.EnvironmentVariableBase {
fleet?: string | null; // fleet slug
appName?: string | null; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface DeviceServiceEnvironmentVariableInfo
extends SDK.DeviceServiceEnvironmentVariable {
fleet?: string; // fleet slug
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
interface ServiceEnvironmentVariableInfo
extends SDK.ServiceEnvironmentVariable {
fleet?: string; // fleet slug
appName?: string; // application name
deviceUUID?: string; // device UUID
serviceName?: string; // service name
}
export default class EnvsCmd extends Command {
public static description = stripIndent`
List the environment or config variables of a fleet, device or service.
List the environment or config variables of an application, device or service.
List the environment or configuration variables of a fleet, device or
service, as selected by the respective command-line options. (A service
corresponds to a Docker image/container in a microservices fleet.)
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
The results include fleet-wide (multiple devices), device-specific (multiple
services on a specific device) and service-specific variables that apply to the
selected fleet, device or service. It can be thought of as including inherited
variables; for example, a service inherits device-wide variables, and a device
inherits fleet-wide variables.
The results include application-wide (fleet), device-wide (multiple services on
a device) and service-specific variables that apply to the selected application,
device or service. It can be thought of as including "inherited" variables;
for example, a service inherits device-wide variables, and a device inherits
application-wide variables.
The printed output may include DEVICE and/or SERVICE columns to distinguish
between fleet-wide, device-specific and service-specific variables.
between application-wide, device-specific and service-specific variables.
An asterisk in these columns indicates that the variable applies to
"all devices" or "all services".
@ -81,20 +83,22 @@ export default class EnvsCmd extends Command {
types like lists and empty strings. The 'jq' utility may be helpful in shell
scripts (https://stedolan.github.io/jq/manual/). When --json is used, an empty
JSON array ([]) is printed instead of an error message when no variables exist
for the given query. When querying variables for a device, note that the fleet
name may be null in JSON output (or 'N/A' in tabular output) if the fleet that
the device belonged to is no longer accessible by the current user (for example,
in case the current user was removed from the fleet by the fleet's owner).
for the given query. When querying variables for a device, note that the
application name may be null in JSON output (or 'N/A' in tabular output) if the
application linked to the device is no longer accessible by the current user
(for example, in case the current user has been removed from the application
by its owner).
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena envs --fleet myorg/myfleet',
'$ balena envs --fleet MyFleet --json',
'$ balena envs --fleet MyFleet --service MyService',
'$ balena envs --fleet MyFleet --service MyService',
'$ balena envs --fleet MyFleet --config',
'$ balena envs --application MyApp',
'$ balena envs --application myorg/myapp',
'$ balena envs --application MyApp --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --json',
'$ balena envs --device 7cf02a6 --config --json',
@ -104,35 +108,44 @@ export default class EnvsCmd extends Command {
public static usage = 'envs';
public static flags: flags.Input<FlagsDef> = {
fleet: { ...cf.fleet, exclusive: ['device'] },
...(isV13()
? {}
: {
all: flags.boolean({
default: false,
description: stripIndent`
No-op since balena CLI v12.0.0.`,
hidden: true,
}),
}),
application: { exclusive: ['device'], ...cf.application },
config: flags.boolean({
default: false,
char: 'c',
description: 'show configuration variables only',
exclusive: ['service'],
}),
device: { ...cf.device, exclusive: ['fleet'] },
device: { exclusive: ['application'], ...cf.device },
help: cf.help,
json: cf.json,
service: { ...cf.service, exclusive: ['config'] },
verbose: cf.verbose,
service: { exclusive: ['config'], ...cf.service },
};
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const variables: EnvironmentVariableInfo[] = [];
await Command.checkLoggedIn();
if (!options.fleet && !options.device) {
throw new ExpectedError('Missing --fleet or --device option');
if (!options.application && !options.device) {
throw new ExpectedError('You must specify an application or device');
}
const balena = getBalenaSdk();
let fleetSlug: string | undefined = options.fleet
? await (await import('../utils/sdk')).getFleetSlug(balena, options.fleet)
: undefined;
let appNameOrSlug = options.application;
let fullUUID: string | undefined; // as oppposed to the short, 7-char UUID
if (options.device) {
@ -141,27 +154,27 @@ export default class EnvsCmd extends Command {
balena,
options.device,
['uuid'],
['slug'],
['app_name'],
);
fullUUID = device.uuid;
if (app) {
fleetSlug = app.slug;
appNameOrSlug = app.app_name;
}
}
if (fleetSlug && options.service) {
await validateServiceName(balena, options.service, fleetSlug);
if (appNameOrSlug && options.service) {
await validateServiceName(balena, options.service, appNameOrSlug);
}
variables.push(...(await getAppVars(balena, fleetSlug, options)));
variables.push(...(await getAppVars(balena, appNameOrSlug, options)));
if (fullUUID) {
variables.push(
...(await getDeviceVars(balena, fullUUID, fleetSlug, options)),
...(await getDeviceVars(balena, fullUUID, appNameOrSlug, options)),
);
}
if (!options.json && variables.length === 0) {
const target =
(options.service ? `service "${options.service}" of ` : '') +
(options.fleet
? `fleet "${options.fleet}"`
(options.application
? `application "${options.application}"`
: `device "${options.device}"`);
throw new ExpectedError(`No environment variables found for ${target}`);
}
@ -173,14 +186,15 @@ export default class EnvsCmd extends Command {
varArray: EnvironmentVariableInfo[],
options: FlagsDef,
) {
const fields = ['id', 'name', 'value', 'fleet'];
const fields = ['id', 'name', 'value'];
// Replace undefined app names with 'N/A' or null
varArray = varArray.map((i: EnvironmentVariableInfo) => {
i.fleet ||= options.json ? null : 'N/A';
i.appName = i.appName || (options.json ? null : 'N/A');
return i;
});
fields.push(options.json ? 'appName' : 'appName => APPLICATION');
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
@ -189,9 +203,9 @@ export default class EnvsCmd extends Command {
}
if (options.json) {
const { pickAndRename } = await import('../utils/helpers');
const mapped = varArray.map((o) => pickAndRename(o, fields));
this.log(JSON.stringify(mapped, null, 4));
this.log(
stringifyVarArray<SDK.EnvironmentVariableBase>(varArray, fields),
);
} else {
this.log(
getVisuals().table.horizontal(
@ -206,14 +220,14 @@ export default class EnvsCmd extends Command {
async function validateServiceName(
sdk: SDK.BalenaSDK,
serviceName: string,
fleetSlug: string,
appName: string,
) {
const services = await sdk.models.service.getAllByApplication(fleetSlug, {
const services = await sdk.models.service.getAllByApplication(appName, {
$filter: { service_name: serviceName },
});
if (services.length === 0) {
throw new ExpectedError(
`Service "${serviceName}" not found for fleet "${fleetSlug}"`,
`Service "${serviceName}" not found for application "${appName}"`,
);
}
}
@ -227,17 +241,17 @@ async function validateServiceName(
*/
async function getAppVars(
sdk: SDK.BalenaSDK,
fleetSlug: string | undefined,
appNameOrSlug: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const appVars: EnvironmentVariableInfo[] = [];
if (!fleetSlug) {
if (!appNameOrSlug) {
return appVars;
}
const vars = await sdk.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(fleetSlug);
fillInInfoFields(vars, fleetSlug);
].getAllByApplication(appNameOrSlug);
fillInInfoFields(vars, appNameOrSlug);
appVars.push(...vars);
if (!options.config) {
const pineOpts: SDK.PineOptions<SDK.ServiceEnvironmentVariable> = {
@ -253,10 +267,10 @@ async function getAppVars(
};
}
const serviceVars = await sdk.models.service.var.getAllByApplication(
fleetSlug,
appNameOrSlug,
pineOpts,
);
fillInInfoFields(serviceVars, fleetSlug);
fillInInfoFields(serviceVars, appNameOrSlug);
appVars.push(...serviceVars);
}
return appVars;
@ -269,7 +283,7 @@ async function getAppVars(
async function getDeviceVars(
sdk: SDK.BalenaSDK,
fullUUID: string,
fleetSlug: string | undefined,
appNameOrSlug: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const printedUUID = options.json ? fullUUID : options.device!;
@ -278,7 +292,7 @@ async function getDeviceVars(
const deviceConfigVars = await sdk.models.device.configVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceConfigVars, fleetSlug, printedUUID);
fillInInfoFields(deviceConfigVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceConfigVars);
} else {
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
@ -299,13 +313,13 @@ async function getDeviceVars(
fullUUID,
pineOpts,
);
fillInInfoFields(deviceServiceVars, fleetSlug, printedUUID);
fillInInfoFields(deviceServiceVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceServiceVars);
const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceEnvVars, fleetSlug, printedUUID);
fillInInfoFields(deviceEnvVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceEnvVars);
}
return deviceVars;
@ -321,7 +335,7 @@ function fillInInfoFields(
| EnvironmentVariableInfo[]
| DeviceServiceEnvironmentVariableInfo[]
| ServiceEnvironmentVariableInfo[],
fleetSlug?: string,
appNameOrSlug?: string,
deviceUUID?: string,
) {
for (const envVar of varArray) {
@ -330,13 +344,33 @@ function fillInInfoFields(
envVar.serviceName = (envVar.service as SDK.Service[])[0]?.service_name;
} else if ('service_install' in envVar) {
// envVar is of type DeviceServiceEnvironmentVariableInfo
envVar.serviceName = (
(envVar.service_install as SDK.ServiceInstall[])[0]
?.installs__service as SDK.Service[]
)[0]?.service_name;
envVar.serviceName = ((envVar.service_install as SDK.ServiceInstall[])[0]
?.installs__service as SDK.Service[])[0]?.service_name;
}
envVar.fleet = fleetSlug;
envVar.appName = appNameOrSlug;
envVar.serviceName = envVar.serviceName || '*';
envVar.deviceUUID = deviceUUID || '*';
}
}
/**
* Transform each object (item) of varArray to preserve only the
* fields (keys) listed in the fields argument.
*/
function stringifyVarArray<T = Dictionary<any>>(
varArray: T[],
fields: string[],
): string {
const transformed = varArray.map((o: Dictionary<any>) =>
_.transform(
o,
(result, value, key) => {
if (fields.includes(key)) {
result[key] = value;
}
},
{} as Dictionary<any>,
),
);
return JSON.stringify(transformed, null, 4);
}

View File

@ -1,108 +0,0 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { isV14 } from '../utils/version';
import type { DataSetOutputOptions } from '../framework';
interface ExtendedApplication extends ApplicationWithDeviceType {
device_count: number;
online_devices: number;
device_type?: string;
}
interface FlagsDef extends DataSetOutputOptions {
help: void;
verbose?: boolean;
}
export default class FleetsCmd extends Command {
public static description = stripIndent`
List all fleets.
List all your balena fleets.
For detailed information on a particular fleet, use
\`balena fleet <fleet>\`
`;
public static examples = ['$ balena fleets'];
public static usage = 'fleets';
public static flags: flags.Input<FlagsDef> = {
...(isV14() ? cf.dataSetOutputFlags : {}),
help: cf.help,
};
public static authenticated = true;
public static primary = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(FleetsCmd);
const balena = getBalenaSdk();
// Get applications
const applications =
(await balena.models.application.getAllDirectlyAccessible({
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
})) as ExtendedApplication[];
// Add extended properties
applications.forEach((application) => {
application.device_count = application.owns__device?.length ?? 0;
application.online_devices =
application.owns__device?.filter((d) => d.is_online).length || 0;
application.device_type = application.is_for__device_type[0].slug;
});
if (isV14()) {
await this.outputData(
applications,
[
'id',
'app_name',
'slug',
'device_type',
'device_count',
'online_devices',
],
options,
);
} else {
console.log(
getVisuals().table.horizontal(applications, [
'id',
'app_name => NAME',
'slug',
'device_type',
'online_devices',
'device_count',
]),
);
}
}
}

View File

@ -63,7 +63,6 @@ export default class OsinitCmd extends Command {
public static hidden = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params } = this.parse<{}, ArgsDef>(OsinitCmd);

View File

@ -0,0 +1,55 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Command from '../../command';
import { stripIndent } from '../../utils/lazy';
// 'Internal' commands are called during the execution of other commands.
// `scandevices` is called during by `join`,`leave'.
// TODO: These should be refactored to modules/functions, and removed
// See previous `internal sudo` refactor:
// - https://github.com/balena-io/balena-cli/pull/1455/files
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308357
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308526
export default class ScandevicesCmd extends Command {
public static description = stripIndent`
Scan for local balena-enabled devices and show a picker to choose one.
Don't use this command directly!
`;
public static usage = 'internal scandevices';
public static root = true;
public static hidden = true;
public async run() {
const { forms } = await import('balena-sync');
try {
const hostnameOrIp = await forms.selectLocalBalenaOsDevice();
return console.error(`==> Selected device: ${hostnameOrIp}`);
} catch (e) {
if (e.message.toLowerCase().includes('could not find any')) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(e);
} else {
throw e;
}
}
}
}

View File

@ -23,7 +23,7 @@ import { applicationIdInfo } from '../utils/messages';
import { parseAsLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
fleet?: string;
application?: string;
pollInterval?: number;
help?: void;
}
@ -34,22 +34,22 @@ interface ArgsDef {
export default class JoinCmd extends Command {
public static description = stripIndent`
Move a local device to a fleet on another balena server.
Move a local device to an application on another balena server.
Move a local device to a fleet on another balena server, causing
Move a local device to an application on another balena server, causing
the device to "join" the new server. The device must be running balenaOS.
For example, you could provision a device against an openBalena installation
where you perform end-to-end tests and then move it to balenaCloud when it's
ready for production.
To move a device between fleets on the same server, use the
To move a device between applications on the same server, use the
\`balena device move\` command instead of \`balena join\`.
If you don't specify a device hostname or IP, this command will automatically
scan the local network for balenaOS devices and prompt you to select one
from an interactive picker. This may require administrator/root privileges.
Likewise, if the fleet option is not provided then a picker will be shown.
from an interactive picker. This requires root privileges. Likewise, if
the application flag is not provided then a picker will be shown.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
@ -57,10 +57,10 @@ export default class JoinCmd extends Command {
public static examples = [
'$ balena join',
'$ balena join balena.local',
'$ balena join balena.local --fleet MyFleet',
'$ balena join balena.local -f myorg/myfleet',
'$ balena join balena.local --application MyApp',
'$ balena join balena.local -a myorg/myapp',
'$ balena join 192.168.1.25',
'$ balena join 192.168.1.25 --fleet MyFleet',
'$ balena join 192.168.1.25 --application MyApp',
];
public static args = [
@ -75,7 +75,7 @@ export default class JoinCmd extends Command {
public static usage = 'join [deviceIpOrHostname]';
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
application: cf.application,
pollInterval: flags.integer({
description: 'the interval in minutes to check for updates',
char: 'i',
@ -98,7 +98,7 @@ export default class JoinCmd extends Command {
logger,
sdk,
params.deviceIpOrHostname,
options.fleet,
options.application,
options.pollInterval,
);
}

View File

@ -18,7 +18,7 @@
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { stripIndent } from '../utils/lazy';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { parseAsLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
@ -31,9 +31,9 @@ interface ArgsDef {
export default class LeaveCmd extends Command {
public static description = stripIndent`
Remove a local device from its balena fleet.
Remove a local device from its balena application.
Remove a local device from its balena fleet, causing the device to
Remove a local device from its balena application, causing the device to
"leave" the server it is provisioned on. This effectively makes the device
"unmanaged". The device must be running balenaOS.
@ -42,7 +42,7 @@ export default class LeaveCmd extends Command {
If you don't specify a device hostname or IP, this command will automatically
scan the local network for balenaOS devices and prompt you to select one
from an interactive picker. This may require administrator/root privileges.
from an interactive picker. This usually requires root privileges.
`;
public static examples = [
@ -72,7 +72,8 @@ export default class LeaveCmd extends Command {
const { args: params } = this.parse<FlagsDef, ArgsDef>(LeaveCmd);
const promote = await import('../utils/promote');
const sdk = getBalenaSdk();
const logger = await Command.getLogger();
return promote.leave(logger, params.deviceIpOrHostname);
return promote.leave(logger, sdk, params.deviceIpOrHostname);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,7 +16,6 @@
*/
import { flags } from '@oclif/command';
import { promisify } from 'util';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { stripIndent } from '../../utils/lazy';
@ -56,48 +55,67 @@ export default class LocalConfigureCmd extends Command {
};
public static root = true;
public static offlineCompatible = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(LocalConfigureCmd);
const { promisify } = await import('util');
const path = await import('path');
const umount = await import('umount');
const umountAsync = promisify(umount.umount);
const isMountedAsync = promisify(umount.isMounted);
const reconfix = await import('reconfix');
const { denyMount, safeUmount } = await import('../../utils/umount');
const denymount = promisify(await import('denymount'));
const Logger = await import('../../utils/logger');
const logger = Logger.getLogger();
const configurationSchema = await this.prepareConnectionFile(params.target);
await denyMount(params.target, async () => {
// TODO: safeUmount umounts drives like '/dev/sdc', but does not
// umount image files like 'balena.img'
await safeUmount(params.target);
const config = await reconfix.readConfiguration(
configurationSchema,
params.target,
if (await isMountedAsync(params.target)) {
await umountAsync(params.target);
}
const dmOpts: any = {};
if (process.pkg) {
// when running in a standalone pkg install, the 'denymount'
// executable is placed on the same folder as process.execPath
dmOpts.executablePath = path.join(
path.dirname(process.execPath),
'denymount',
);
logger.logDebug('Current config:');
logger.logDebug(JSON.stringify(config));
const answers = await this.getConfiguration(config);
logger.logDebug('New config:');
logger.logDebug(JSON.stringify(answers));
if (!answers.hostname) {
await this.removeHostname(configurationSchema);
}
await reconfix.writeConfiguration(
configurationSchema,
answers,
params.target,
);
});
}
const dmHandler = (cb: () => void) =>
reconfix
.readConfiguration(configurationSchema, params.target)
.then(async (config: any) => {
logger.logDebug('Current config:');
logger.logDebug(JSON.stringify(config));
const answers = await this.getConfiguration(config);
logger.logDebug('New config:');
logger.logDebug(JSON.stringify(answers));
if (!answers.hostname) {
await this.removeHostname(configurationSchema);
}
return await reconfix.writeConfiguration(
configurationSchema,
answers,
params.target,
);
})
.asCallback(cb);
await denymount(params.target, dmHandler, dmOpts);
console.log('Done!');
}
readonly BOOT_PARTITION = 1;
readonly CONNECTIONS_FOLDER = '/system-connections';
getConfigurationSchema(bootPartition?: number, connectionFileName?: string) {
getConfigurationSchema(connectionFileName?: string) {
connectionFileName ??= 'resin-wifi';
return {
mapper: [
@ -113,12 +131,6 @@ export default class LocalConfigureCmd extends Command {
},
domain: [['config_json', 'hostname']],
},
{
template: {
developmentMode: '{{developmentMode}}',
},
domain: [['config_json', 'developmentMode']],
},
{
template: {
wifi: {
@ -142,14 +154,14 @@ export default class LocalConfigureCmd extends Command {
path: this.CONNECTIONS_FOLDER.slice(1),
// Reconfix still uses the older resin-image-fs, so still needs an
// object-based partition definition.
partition: bootPartition,
partition: this.BOOT_PARTITION,
},
},
config_json: {
type: 'json',
location: {
path: 'config.json',
partition: bootPartition,
partition: this.BOOT_PARTITION,
},
},
},
@ -169,13 +181,6 @@ export default class LocalConfigureCmd extends Command {
name: 'networkKey',
default: data.networkKey,
},
{
message:
'Enable development mode? (Open ports and root access - Not for production!)',
type: 'confirm',
name: 'developmentMode',
default: false,
},
{
message: 'Do you want to set advanced settings?',
type: 'confirm',
@ -248,13 +253,12 @@ export default class LocalConfigureCmd extends Command {
*/
async prepareConnectionFile(target: string) {
const _ = await import('lodash');
const imagefs = await import('balena-image-fs');
const { getBootPartition } = await import('balena-config-json');
const imagefs = await import('resin-image-fs');
const bootPartition = await getBootPartition(target);
const files = await imagefs.interact(target, bootPartition, async (_fs) => {
return await promisify(_fs.readdir)(this.CONNECTIONS_FOLDER);
const files = await imagefs.listDirectory({
image: target,
partition: this.BOOT_PARTITION,
path: this.CONNECTIONS_FOLDER,
});
let connectionFileName;
@ -262,18 +266,18 @@ export default class LocalConfigureCmd extends Command {
// The required file already exists, nothing to do
} else if (_.includes(files, 'resin-sample.ignore')) {
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
await imagefs.interact(target, bootPartition, async (_fs) => {
const readFileAsync = promisify(_fs.readFile);
const writeFileAsync = promisify(_fs.writeFile);
const contents = await readFileAsync(
`${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
{ encoding: 'utf8' },
);
return await writeFileAsync(
`${this.CONNECTIONS_FOLDER}/resin-wifi`,
contents,
);
});
await imagefs.copy(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
},
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
);
} else if (_.includes(files, 'resin-sample')) {
// Legacy mode, to be removed later
// We return the file name override from this branch
@ -285,14 +289,16 @@ export default class LocalConfigureCmd extends Command {
connectionFileName = 'resin-sample';
} else {
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
await imagefs.interact(target, bootPartition, async (_fs) => {
return await promisify(_fs.writeFile)(
`${this.CONNECTIONS_FOLDER}/resin-wifi`,
this.CONNECTION_FILE,
);
});
await imagefs.writeFile(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
this.CONNECTION_FILE,
);
}
return await this.getConfigurationSchema(bootPartition, connectionFileName);
return await this.getConfigurationSchema(connectionFileName);
}
async removeHostname(schema: any) {

View File

@ -16,11 +16,16 @@
*/
import { flags } from '@oclif/command';
import type { BlockDevice } from 'etcher-sdk/build/source-destination';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getChalk, getVisuals, stripIndent } from '../../utils/lazy';
import {
getChalk,
getCliForm,
getVisuals,
stripIndent,
} from '../../utils/lazy';
import type * as SDK from 'etcher-sdk';
interface FlagsDef {
yes: boolean;
@ -65,43 +70,33 @@ export default class LocalFlashCmd extends Command {
help: cf.help,
};
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
LocalFlashCmd,
);
if (process.platform === 'linux') {
const { promisify } = await import('util');
const { exec } = await import('child_process');
const execAsync = promisify(exec);
let distroVersion = '';
try {
const info = await execAsync('cat /proc/version');
distroVersion = info.stdout.toLowerCase();
} catch {
// pass
}
if (distroVersion.includes('microsoft')) {
throw new ExpectedError(stripIndent`
This command is known not to work on WSL. Please use a CLI release
for Windows (not WSL), or balenaEtcher.`);
}
}
const { sourceDestination, multiWrite } = await import('etcher-sdk');
const drive = await this.getDrive(options);
const { confirm } = await import('../../utils/patterns');
await confirm(
options.yes,
'This will erase the selected drive. Are you sure?',
);
const yes =
options.yes ||
(await getCliForm().ask({
message: 'This will erase the selected drive. Are you sure?',
type: 'confirm',
name: 'yes',
default: false,
}));
const { sourceDestination, multiWrite } = await import('etcher-sdk');
const file = new sourceDestination.File({
path: params.image,
});
if (!yes) {
console.log(getChalk().red.bold('Aborted image flash'));
process.exit(0);
}
const file = new sourceDestination.File(
params.image,
sourceDestination.File.OpenFlags.Read,
);
const source = await file.getInnerSource();
const visuals = getVisuals();
@ -110,37 +105,29 @@ export default class LocalFlashCmd extends Command {
verifying: new visuals.Progress('Validating'),
};
await multiWrite.pipeSourceToDestinations({
await multiWrite.pipeSourceToDestinations(
source,
destinations: [drive],
onFail: (_, error) => {
console.error(getChalk().red.bold(error.message));
if (error.message.includes('EACCES')) {
console.error(
getChalk().red.bold(
'Try running this command with elevated privileges, with sudo or in a shell running with admininstrator privileges.',
),
);
}
[drive],
(_, error) => {
// onFail
console.log(getChalk().red.bold(error.message));
},
onProgress: (progress) => {
(progress: SDK.multiWrite.MultiDestinationProgress) => {
// onProgress
progressBars[progress.type].update(progress);
},
verify: true,
});
true, // verify
);
}
async getDrive(options: { drive?: string }): Promise<BlockDevice> {
async getDrive(options: {
drive?: string;
}): Promise<SDK.sourceDestination.BlockDevice> {
const drive = options.drive || (await getVisuals().drive('Select a drive'));
const sdk = await import('etcher-sdk');
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter({
includeSystemDrives: () => false,
unmountOnSuccess: false,
write: true,
direct: true,
});
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
const scanner = new sdk.scanner.Scanner([adapter]);
await scanner.start();
try {

View File

@ -187,7 +187,7 @@ ${messages.reachingOut}`);
if (loginType === 'register') {
const open = await import('open');
const signupUrl = `https://dashboard.${balenaUrl}/signup`;
await open(signupUrl, { wait: false });
open(signupUrl, { wait: false });
throw new ExpectedError(`Please sign up at ${signupUrl}`);
}

View File

@ -35,10 +35,10 @@ interface ArgsDef {
export default class OsBuildConfigCmd extends Command {
public static description = stripIndent`
Prepare a configuration file for use by the 'os configure' command.
Build an OS config and save it to a JSON file.
Interactively generate a configuration file that can then be used as
non-interactive input by the 'balena os configure' command.
Interactively generate an OS config once, so that the generated config
file can be used in \`balena os configure\`, skipping the interactive part.
`;
public static examples = [

View File

@ -17,32 +17,32 @@
import { flags } from '@oclif/command';
import type * as BalenaSdk from 'balena-sdk';
import { promisify } from 'util';
import * as _ from 'lodash';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { applicationIdInfo, devModeInfo } from '../../utils/messages';
import { applicationIdInfo } from '../../utils/messages';
const BOOT_PARTITION = 1;
const CONNECTIONS_FOLDER = '/system-connections';
interface FlagsDef {
advanced?: boolean;
fleet?: string;
application?: string;
app?: string;
config?: string;
'config-app-update-poll-interval'?: number;
'config-network'?: string;
'config-wifi-key'?: string;
'config-wifi-ssid'?: string;
dev?: boolean; // balenaOS development variant
device?: string; // device UUID
'device-api-key'?: string;
'device-type'?: string;
help?: void;
version?: string;
'system-connection': string[];
'initial-device-name'?: string;
'provisioning-key-name'?: string;
}
interface ArgsDef {
@ -51,21 +51,23 @@ interface ArgsDef {
interface Answers {
appUpdatePollInterval: number; // in minutes
developmentMode?: boolean; // balenaOS development variant
deviceType: string; // e.g. "raspberrypi3"
network: 'ethernet' | 'wifi';
version: string; // e.g. "2.32.0+rev1"
wifiSsid?: string;
wifiKey?: string;
provisioningKeyName?: string;
}
const deviceApiKeyDeprecationMsg = stripIndent`
The --device-api-key option is deprecated and will be removed in a future release.
A suitable key is automatically generated or fetched if this option is omitted.`;
export default class OsConfigureCmd extends Command {
public static description = stripIndent`
Configure a previously downloaded balenaOS image.
Configure a previously downloaded balenaOS image for a specific device type
or fleet.
Configure a previously downloaded balenaOS image for a specific device type or
balena application.
Configuration settings such as WiFi authentication will be taken from the
following sources, in precedence order:
@ -73,17 +75,17 @@ export default class OsConfigureCmd extends Command {
2. A given \`config.json\` file specified with the \`--config\` option.
3. User input through interactive prompts (text menus).
The --device-type option is used to override the fleet's default device type,
in case of a fleet with mixed device types.
The --device-type option may be used to override the application's default
device type, in case of an application with mixed device types.
${devModeInfo.split('\n').join('\n\t\t')}
The --system-connection (-c) option is used to inject NetworkManager connection
The --system-connection (-c) option can be used to inject NetworkManager connection
profiles for additional network interfaces, such as cellular/GSM or additional
WiFi or ethernet connections. This option may be passed multiple times in case there
are multiple files to inject. See connection profile examples and reference at:
https://www.balena.io/docs/reference/OS/network/2.x/
https://developer.gnome.org/NetworkManager/stable/ref-settings.html
https://developer.gnome.org/NetworkManager/stable/nm-settings.html
${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t')}
${applicationIdInfo.split('\n').join('\n\t\t')}
@ -95,10 +97,12 @@ export default class OsConfigureCmd extends Command {
public static examples = [
'$ balena os configure ../path/rpi3.img --device 7cf02a6',
'$ balena os configure ../path/rpi3.img --fleet myorg/myfleet',
'$ balena os configure ../path/rpi3.img --fleet MyFleet --version 2.12.7',
'$ balena os configure ../path/rpi3.img -f MyFinFleet --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img -f MyFinFleet --device-type raspberrypi3 --config myWifiConfig.json',
'$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>',
'$ balena os configure ../path/rpi3.img --app MyApp',
'$ balena os configure ../path/rpi3.img -a myorg/myapp',
'$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3 --config myWifiConfig.json',
];
public static args = [
@ -117,15 +121,15 @@ export default class OsConfigureCmd extends Command {
description:
'ask advanced configuration questions (when in interactive mode)',
}),
fleet: { ...cf.fleet, exclusive: ['device'] },
application: { ...cf.application, exclusive: ['app', 'device'] },
app: { ...cf.app, exclusive: ['application', 'device'] },
config: flags.string({
description:
'path to a pre-generated config.json file to be injected in the OS image',
exclusive: ['provisioning-key-name'],
}),
'config-app-update-poll-interval': flags.integer({
description:
'supervisor cloud polling interval in minutes (e.g. for variable updates)',
'interval (in minutes) for the on-device balena supervisor periodic app update check',
}),
'config-network': flags.string({
description: 'device network type (non-interactive configuration)',
@ -137,11 +141,15 @@ export default class OsConfigureCmd extends Command {
'config-wifi-ssid': flags.string({
description: 'WiFi SSID (network name) (non-interactive configuration)',
}),
dev: cf.dev,
device: { ...cf.device, exclusive: ['fleet', 'provisioning-key-name'] },
device: { exclusive: ['app', 'application'], ...cf.device },
'device-api-key': flags.string({
char: 'k',
description:
'custom device API key (DEPRECATED and only supported with balenaOS 2.0.3+)',
}),
'device-type': flags.string({
description:
'device type slug (e.g. "raspberrypi3") to override the fleet device type',
'device type slug (e.g. "raspberrypi3") to override the application device type',
}),
'initial-device-name': flags.string({
description:
@ -157,19 +165,16 @@ export default class OsConfigureCmd extends Command {
description:
"paths to local files to place into the 'system-connections' directory",
}),
'provisioning-key-name': flags.string({
description: 'custom key name assigned to generated provisioning api key',
exclusive: ['config', 'device'],
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
OsConfigureCmd,
);
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
await validateOptions(options);
@ -196,7 +201,7 @@ export default class OsConfigureCmd extends Command {
};
deviceTypeSlug = device.is_of__device_type[0].slug;
} else {
app = (await getApplication(balena, options.fleet!, {
app = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
@ -217,28 +222,25 @@ export default class OsConfigureCmd extends Command {
configJson = JSON.parse(rawConfig);
}
const osVersion =
options.version ||
(await getOsVersionFromImage(params.image, deviceTypeManifest, devInit));
const { validateDevOptionAndWarn } = await import('../../utils/config');
await validateDevOptionAndWarn(options.dev, osVersion);
const answers: Answers = await askQuestionsForDeviceType(
deviceTypeManifest,
options,
configJson,
);
if (options.fleet) {
if (options.application) {
answers.deviceType = deviceTypeSlug;
}
answers.version = osVersion;
answers.developmentMode = options.dev;
answers.provisioningKeyName = options['provisioning-key-name'];
answers.version =
options.version ||
(await getOsVersionFromImage(params.image, deviceTypeManifest, devInit));
if (_.isEmpty(configJson)) {
if (device) {
configJson = await generateDeviceConfig(device, undefined, answers);
configJson = await generateDeviceConfig(
device,
options['device-api-key'],
answers,
);
} else {
configJson = await generateApplicationConfig(app!, answers);
}
@ -277,19 +279,17 @@ export default class OsConfigureCmd extends Command {
};
}),
);
const { getBootPartition } = await import('balena-config-json');
const bootPartition = await getBootPartition(params.image);
const imagefs = await import('balena-image-fs');
const imagefs = await import('resin-image-fs');
for (const { name, content } of files) {
await imagefs.interact(image, bootPartition, async (_fs) => {
return await promisify(_fs.writeFile)(
path.join(CONNECTIONS_FOLDER, name),
content,
);
});
await imagefs.writeFile(
{
image,
partition: BOOT_PARTITION,
path: path.join(CONNECTIONS_FOLDER, name),
},
content,
);
console.info(`Copied system-connection file: ${name}`);
}
}
@ -297,18 +297,38 @@ export default class OsConfigureCmd extends Command {
}
async function validateOptions(options: FlagsDef) {
if (process.platform === 'win32') {
throw new ExpectedError(stripIndent`
Unsupported platform error: the 'balena os configure' command currently requires
the Windows Subsystem for Linux in order to run on Windows. It was tested with
the Ubuntu 18.04 distribution from the Microsoft Store. With WSL, a balena CLI
release for Linux (rather than Windows) should be installed: for example, the
standalone zip package for Linux. (It is possible to have both a Windows CLI
release and a Linux CLI release installed simultaneously.) For more information
on WSL and the balena CLI installation options, please check:
- https://docs.microsoft.com/en-us/windows/wsl/about
- https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
`);
}
// The 'device' and 'application' options are declared "exclusive" in the oclif
// flag definitions above, so oclif will enforce that they are not both used together.
if (!options.device && !options.fleet) {
if (!options.device && !options.application) {
throw new ExpectedError(
"Either the '--device' or the '--fleet' option must be provided",
"Either the '--device' or the '--application' option must be provided",
);
}
if (!options.fleet && options['device-type']) {
if (!options.application && options['device-type']) {
throw new ExpectedError(
"The '--device-type' option can only be used in conjunction with the '--fleet' option",
"The '--device-type' option can only be used in conjunction with the '--application' option",
);
}
if (options['device-api-key']) {
console.error(stripIndent`
-------------------------------------------------------------------------------------------
Warning: ${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t\t')}
-------------------------------------------------------------------------------------------
`);
}
await Command.checkLoggedIn();
}
@ -358,7 +378,7 @@ async function checkDeviceTypeCompatibility(
const helpers = await import('../../utils/helpers');
if (!helpers.areDeviceTypesCompatible(appDeviceType, optionDeviceType)) {
throw new ExpectedError(
`Device type ${options['device-type']} is incompatible with fleet ${options.fleet}`,
`Device type ${options['device-type']} is incompatible with application ${options.application}`,
);
}
}
@ -383,13 +403,7 @@ async function askQuestionsForDeviceType(
options: FlagsDef,
configJson?: import('../../utils/config').ImgConfig,
): Promise<Answers> {
const answerSources: any[] = [
{
...camelifyConfigOptions(options),
app: options.fleet,
application: options.fleet,
},
];
const answerSources: any[] = [camelifyConfigOptions(options)];
const defaultAnswers: Partial<Answers> = {};
const questions: any = deviceType.options;
let extraOpts: { override: object } | undefined;

View File

@ -34,33 +34,30 @@ export default class OsDownloadCmd extends Command {
public static description = stripIndent`
Download an unconfigured OS image.
Download an unconfigured OS image for the specified device type.
Check available device types with 'balena devices supported'.
Download an unconfigured OS image for a certain device type.
Check available types with \`balena devices supported\`
Note: Currently this command only works with balenaCloud, not openBalena.
If using openBalena, please download the OS from: https://www.balena.io/os/
The '--version' option is used to select the balenaOS version. If omitted,
the latest released version is downloaded (and if only pre-release versions
exist, the latest pre-release version is downloaded).
If version is not specified the newest stable (non-pre-release) version of OS
is downloaded (if available), otherwise the newest version (if all existing
versions for the given device type are pre-release).
Use '--version menu' or '--version menu-esr' to interactively select the
OS version. The latter lists ESR versions which are only available for
download on Production and Enterprise plans. See also:
https://www.balena.io/docs/reference/OS/extended-support-release/
You can pass \`--version menu\` to pick the OS version from the interactive menu
of all available versions.
Development images can be selected by appending \`.dev\` to the version.
To download a development image append \`.dev\` to the version or select from
the interactive menu.
`;
public static examples = [
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 2.60.1+rev1',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 2.60.1+rev1.dev',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^2.60.0',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 2021.10.2.prod',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu-esr',
];
public static args = [
@ -81,13 +78,11 @@ export default class OsDownloadCmd extends Command {
}),
version: flags.string({
description: stripIndent`
version number (ESR or non-ESR versions),
or semver range (non-ESR versions only),
exact version number, or a valid semver range,
or 'latest' (includes pre-releases),
or 'default' (excludes pre-releases if at least one released version is available),
or 'default' (excludes pre-releases if at least one stable version is available),
or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available),
or 'menu' (interactive menu, non-ESR versions),
or 'menu-esr' (interactive menu, ESR versions)
or 'menu' (will show the interactive menu)
`,
}),
help: cf.help,
@ -98,49 +93,8 @@ export default class OsDownloadCmd extends Command {
OsDownloadCmd,
);
// balenaOS ESR versions require user authentication
if (options.version) {
const { isESR } = await import('balena-image-manager');
if (options.version === 'menu-esr' || isESR(options.version)) {
try {
await OsDownloadCmd.checkLoggedIn();
} catch (e) {
const { ExpectedError, NotLoggedInError } = await import(
'../../errors'
);
if (e instanceof NotLoggedInError) {
throw new ExpectedError(stripIndent`
${e.message}
User authentication is required to download balenaOS ESR versions.`);
}
throw e;
}
}
}
const { downloadOSImage } = await import('../../utils/cloud');
try {
await downloadOSImage(params.type, options.output, options.version);
} catch (e) {
e.deviceTypeSlug = params.type;
e.message ||= '';
if (
e.code === 'BalenaRequestError' ||
e.message.toLowerCase().includes('no such version')
) {
const version = options.version || '';
if (
!version.endsWith('.dev') &&
!version.endsWith('.prod') &&
/^v?\d+\.\d+\.\d+/.test(version)
) {
e.message += `
** Hint: some OS releases require specifying the full OS version including
** the '.prod' or '.dev' suffix, e.g. '--version 2021.10.2.prod'`;
}
}
throw e;
}
await downloadOSImage(params.type, options.output, options.version);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016-2021 Balena Ltd.
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -61,7 +61,12 @@ export default class OsInitializeCmd extends Command {
public static usage = 'os initialize <image>';
public static flags: flags.Input<FlagsDef> = {
type: cf.deviceType,
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: cf.drive,
yes: cf.yes,
help: cf.help,
@ -74,6 +79,8 @@ export default class OsInitializeCmd extends Command {
OsInitializeCmd,
);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const { getManifest, sudo } = await import('../../utils/helpers');
console.info(`Initializing device ${INIT_WARNING_MESSAGE}`);
@ -92,9 +99,9 @@ export default class OsInitializeCmd extends Command {
options.yes,
`This will erase ${answers.drive}. Are you sure?`,
`Going to erase ${answers.drive}.`,
true,
);
const { safeUmount } = await import('../../utils/umount');
await safeUmount(answers.drive);
await umountAsync(answers.drive);
}
await sudo([
@ -106,8 +113,22 @@ export default class OsInitializeCmd extends Command {
]);
if (answers.drive != null) {
const { safeUmount } = await import('../../utils/umount');
await safeUmount(answers.drive);
// TODO: balena local makes use of ejectAsync, see below
// DO we need this / should we do that here?
// getDrive = (drive) ->
// driveListAsync().then (drives) ->
// selectedDrive = _.find(drives, device: drive)
// if not selectedDrive?
// throw new Error("Drive not found: #{drive}")
// return selectedDrive
// if (os.platform() is 'win32') and selectedDrive.mountpoint?
// ejectAsync = Promise.promisify(require('removedrive').eject)
// return ejectAsync(selectedDrive.mountpoint)
await umountAsync(answers.drive);
console.info(`You can safely remove ${answers.drive} now`);
}
}

View File

@ -18,10 +18,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { stripIndent } from '../../utils/lazy';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
interface FlagsDef {
esr?: boolean;
help: void;
}
@ -35,9 +34,6 @@ export default class OsVersionsCmd extends Command {
Show the available balenaOS versions for the given device type.
Check available types with \`balena devices supported\`.
balenaOS ESR versions can be listed with the '--esr' option. See also:
https://www.balena.io/docs/reference/OS/extended-support-release/
`;
public static examples = ['$ balena os versions raspberrypi3'];
@ -54,22 +50,18 @@ export default class OsVersionsCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
esr: flags.boolean({
description: 'select balenaOS ESR versions',
default: false,
}),
};
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
OsVersionsCmd,
);
const { args: params } = this.parse<FlagsDef, ArgsDef>(OsVersionsCmd);
const { formatOsVersion, getOsVersions } = await import(
'../../utils/cloud'
);
const vs = await getOsVersions(params.type, !!options.esr);
const {
versions: vs,
recommended,
} = await getBalenaSdk().models.os.getSupportedVersions(params.type);
console.log(vs.map((v) => formatOsVersion(v)).join('\n'));
vs.forEach((v) => {
console.log(`v${v}` + (v === recommended ? ' (recommended)' : ''));
});
}
}

View File

@ -15,8 +15,8 @@
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import {
getBalenaSdk,
@ -27,20 +27,24 @@ import {
import { applicationIdInfo } from '../utils/messages';
import type { DockerConnectionCliFlags } from '../utils/docker';
import { dockerConnectionCliFlags } from '../utils/docker';
import { parseAsInteger } from '../utils/validation';
import { flags } from '@oclif/command';
import * as _ from 'lodash';
import type { Application, BalenaSDK, PineExpand, Release } from 'balena-sdk';
import type {
Application,
BalenaSDK,
DeviceTypeJson,
PineExpand,
Release,
} from 'balena-sdk';
import type { Preloader } from 'balena-preload';
import { parseAsInteger } from '../utils/validation';
import { ExpectedError } from '../errors';
interface FlagsDef extends DockerConnectionCliFlags {
fleet?: string;
app?: string;
commit?: string;
'splash-image'?: string;
'dont-check-arch': boolean;
'pin-device-to-release': boolean;
'additional-space'?: number;
'add-certificate'?: string[];
help: void;
}
@ -51,34 +55,25 @@ interface ArgsDef {
export default class PreloadCmd extends Command {
public static description = stripIndent`
Preload a release on a disk image (or Edison zip archive).
Preload an app on a disk image (or Edison zip archive).
Preload a release (service images/containers) from a balena fleet, and optionally
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file
in the local disk (a zip file is only accepted for the Intel Edison device type).
After preloading, the balenaOS image file can be flashed to a device's SD card.
When the device boots, it will not need to download the release, as it was
preloaded. This is usually combined with release pinning
(https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/)
to avoid the device downloading a newer release straight away, if available.
Check also the Preloading and Preregistering section of the balena CLI's advanced
masterclass document:
https://www.balena.io/docs/learn/more/masterclasses/advanced-cli/#5-preloading-and-preregistering
When the device boots, it will not need to download the application, as it was
preloaded.
${applicationIdInfo.split('\n').join('\n\t\t')}
Note that the this command requires Docker to be installed, as further detailed
in the balena CLI's installation instructions:
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
The \`--dockerHost\` and \`--dockerPort\` flags allow a remote Docker engine to
be used, however the image file must be accessible to the remote Docker engine
on the same path given on the command line. This is because Docker's bind mount
feature is used to "share" the image with a container that performs the preload.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)
check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
`;
public static examples = [
'$ balena preload balena.img --fleet MyFleet --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0',
'$ balena preload balena.img --fleet myorg/myfleet --splash-image image.png',
'$ balena preload balena.img --app MyApp --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0',
'$ balena preload balena.img --app myorg/myapp --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png',
'$ balena preload balena.img',
];
@ -93,15 +88,13 @@ export default class PreloadCmd extends Command {
public static usage = 'preload <image>';
public static flags: flags.Input<FlagsDef> = {
fleet: cf.fleet,
// TODO: Replace with application/a in #v13?
app: cf.application,
commit: flags.string({
description: `\
The commit hash of the release to preload. Use "current" to specify the current
release (ignored if no appId is given). The current release is usually also the
latest, but can be pinned to a specific release. See:
https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/
https://www.balena.io/docs/learn/more/masterclasses/fleet-management/#63-pin-using-the-api
https://github.com/balena-io-examples/staged-releases\
The commit hash for a specific application release to preload, use "current" to specify the current
release (ignored if no appId is given). The current release is usually also the latest, but can be
manually pinned using https://github.com/balena-io-projects/staged-releases .\
`,
char: 'c',
}),
@ -112,7 +105,7 @@ https://github.com/balena-io-examples/staged-releases\
'dont-check-arch': flags.boolean({
default: false,
description:
'disable architecture compatibility check between image and fleet',
'disables check for matching architecture in image and application',
}),
'pin-device-to-release': flags.boolean({
default: false,
@ -120,11 +113,6 @@ https://github.com/balena-io-examples/staged-releases\
'pin the preloaded device to the preloaded release on provision',
char: 'p',
}),
'additional-space': flags.integer({
description:
'expand the image by this amount of bytes instead of automatically estimating the required amount',
parse: (x) => parseAsInteger(x, 'additional-space'),
}),
'add-certificate': flags.string({
description: `\
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
@ -166,14 +154,6 @@ Can be repeated to add multiple certificates.\
try {
const fs = await import('fs');
await fs.promises.access(params.image);
const path = await import('path');
if (path.extname(params.image) === '.zip') {
console.warn(stripIndent`
------------------------------------------------------------------------------
Warning: A zip file is only accepted for the Intel Edison device type.
------------------------------------------------------------------------------
`);
}
} catch (error) {
throw new ExpectedError(
`The provided image path does not exist: ${params.image}`,
@ -182,9 +162,15 @@ Can be repeated to add multiple certificates.\
// balena-preload currently does not work with numerical app IDs
// Load app here, and use app slug from hereon
const fleetSlug: string | undefined = options.fleet
? await (await import('../utils/sdk')).getFleetSlug(balena, options.fleet)
: undefined;
if (options.app && !options.app.includes('/')) {
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const { getApplication } = await import('../utils/sdk');
const application = await getApplication(balena, options.app);
if (!application) {
throw new ExpectedError(`Application not found: ${options.app}`);
}
options.app = application.slug;
}
const progressBars: {
[key: string]: ReturnType<typeof getVisuals>['Progress'];
@ -220,14 +206,16 @@ Can be repeated to add multiple certificates.\
? 'latest'
: options.commit;
const image = params.image;
const appId = options.app;
const splashImage = options['splash-image'];
const additionalSpace = options['additional-space'];
const dontCheckArch = options['dont-check-arch'] || false;
const pinDevice = options['pin-device-to-release'] || false;
if (dontCheckArch && !fleetSlug) {
if (dontCheckArch && !appId) {
throw new ExpectedError(
'You need to specify a fleet if you disable the architecture check.',
'You need to specify an application if you disable the architecture check.',
);
}
@ -244,7 +232,7 @@ Can be repeated to add multiple certificates.\
const preloader = new balenaPreload.Preloader(
null,
docker,
fleetSlug,
appId,
commit,
image,
splashImage,
@ -252,7 +240,6 @@ Can be repeated to add multiple certificates.\
dontCheckArch,
pinDevice,
certificates,
additionalSpace,
);
let gotSignal = false;
@ -261,17 +248,10 @@ Can be repeated to add multiple certificates.\
if (signal) {
gotSignal = true;
nodeCleanup.uninstall(); // don't call cleanup handler again
preloader
.cleanup()
.then(() => {
// calling process.exit() won't inform parent process of signal
process.kill(process.pid, signal);
})
.catch((e) => {
if (process.env.DEBUG) {
console.error(e);
}
});
preloader.cleanup().then(() => {
// calling process.exit() won't inform parent process of signal
process.kill(process.pid, signal);
});
return false;
}
});
@ -288,7 +268,7 @@ Can be repeated to add multiple certificates.\
preloader.on('error', reject);
resolve(
this.prepareAndPreload(preloader, balena, {
appId: fleetSlug,
appId,
commit,
pinDevice,
}),
@ -311,6 +291,7 @@ Can be repeated to add multiple certificates.\
readonly applicationExpandOptions: PineExpand<Application> = {
owns__release: {
$select: ['id', 'commit', 'end_timestamp', 'composition'],
$orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }],
$expand: {
contains__image: {
$select: ['image'],
@ -324,75 +305,77 @@ Can be repeated to add multiple certificates.\
$filter: {
status: 'success',
},
$orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }],
},
should_be_running__release: {
$select: 'commit',
},
};
allDeviceTypes: DeviceTypeJson.DeviceType[];
async getDeviceTypes() {
if (this.allDeviceTypes === undefined) {
const balena = getBalenaSdk();
const deviceTypes = await balena.models.config.getDeviceTypes();
this.allDeviceTypes = _.sortBy(deviceTypes, 'name');
}
return this.allDeviceTypes;
}
isCurrentCommit(commit: string) {
return commit === 'latest' || commit === 'current';
}
async getDeviceTypesWithSameArch(deviceTypeSlug: string) {
const deviceTypes = await this.getDeviceTypes();
const deviceType = _.find(deviceTypes, { slug: deviceTypeSlug });
if (!deviceType) {
throw new Error(`Device type "${deviceTypeSlug}" not found in API query`);
}
return _(deviceTypes).filter({ arch: deviceType.arch }).map('slug').value();
}
async getApplicationsWithSuccessfulBuilds(deviceTypeSlug: string) {
const balena = getBalenaSdk();
try {
await balena.models.deviceType.get(deviceTypeSlug);
} catch {
throw new Error(`Device type "${deviceTypeSlug}" not found in API query`);
}
return (await balena.models.application.getAllDirectlyAccessible({
$select: ['id', 'slug', 'should_track_latest_release'],
$expand: this.applicationExpandOptions,
$filter: {
// get the apps that are of the same arch as the device type of the image
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: {
dt: {
is_of__cpu_architecture: {
$any: {
$alias: 'ioca',
$expr: {
ioca: {
is_supported_by__device_type: {
$any: {
$alias: 'isbdt',
$expr: {
isbdt: {
slug: deviceTypeSlug,
},
},
},
},
},
},
},
const deviceTypes = await this.getDeviceTypesWithSameArch(deviceTypeSlug);
// TODO: remove the explicit types once https://github.com/balena-io/balena-sdk/pull/889 gets merged
return balena.pine.get<
Application,
Array<
ApplicationWithDeviceType & {
should_be_running__release: [Release?];
}
>
>({
resource: 'my_application',
options: {
$filter: {
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: {
dt: {
slug: { $in: deviceTypes },
},
},
},
},
owns__release: {
$any: {
$alias: 'r',
$expr: {
r: {
status: 'success',
},
},
},
},
},
owns__release: {
$any: {
$alias: 'r',
$expr: {
r: {
status: 'success',
},
},
},
},
$expand: this.applicationExpandOptions,
$select: ['id', 'app_name', 'should_track_latest_release'],
$orderby: 'app_name asc',
},
$orderby: 'slug asc',
})) as Array<
ApplicationWithDeviceType & {
should_be_running__release: [Release?];
}
>;
});
}
async selectApplication(deviceTypeSlug: string) {
@ -409,14 +392,14 @@ Can be repeated to add multiple certificates.\
applicationInfoSpinner.stop();
if (applications.length === 0) {
throw new ExpectedError(
`No fleets found with successful releases for device type '${deviceTypeSlug}'`,
`You have no apps with successful releases for a '${deviceTypeSlug}' device type.`,
);
}
return getCliForm().ask({
message: 'Select a fleet',
message: 'Select an application',
type: 'list',
choices: applications.map((app) => ({
name: app.slug,
name: app.app_name,
value: app,
})),
});
@ -424,7 +407,7 @@ Can be repeated to add multiple certificates.\
selectApplicationCommit(releases: Release[]) {
if (releases.length === 0) {
throw new ExpectedError('This fleet has no successful releases.');
throw new ExpectedError('This application has no successful releases.');
}
const DEFAULT_CHOICE = { name: 'current', value: 'current' };
const choices = [DEFAULT_CHOICE].concat(
@ -457,23 +440,23 @@ Can be repeated to add multiple certificates.\
}
const message = `\
This fleet is set to track the latest release, and non-pinned devices
This application is set to track the latest release, and non-pinned devices
are automatically updated when a new release is available. This may lead to
unexpected behavior: The preloaded device will download and install the latest
release once it is online.
This prompt gives you the opportunity to disable automatic updates for
this fleet now. Note that this would result in the fleet being pinned to
the current latest release, rather than some other release that may have
This prompt gives you the opportunity to disable automatic updates for this
application now. Note that this would result in the application being pinned
to the current latest release, rather than some other release that may have
been selected for preloading. The pinned released may be further managed
through the web dashboard or programatically through the balena API / SDK.
Documentation about release policies and pinning can be found at:
Documentation about release policies and app/device pinning can be found at:
https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/
Alternatively, the --pin-device-to-release flag may be used to pin only the
preloaded device to the selected release.
Would you like to disable automatic updates for this fleet now?\
Would you like to disable automatic updates for this application now?\
`;
const update = await getCliForm().ask({
message,
@ -491,7 +474,7 @@ Would you like to disable automatic updates for this fleet now?\
});
}
async getAppWithReleases(balenaSdk: BalenaSDK, appId: string) {
async getAppWithReleases(balenaSdk: BalenaSDK, appId: string | number) {
const { getApplication } = await import('../utils/sdk');
return (await getApplication(balenaSdk, appId, {
@ -523,7 +506,7 @@ Would you like to disable automatic updates for this fleet now?\
if (this.isCurrentCommit(options.commit)) {
if (!appCommit) {
throw new Error(
`Unexpected empty commit hash for fleet ID "${application.id}"`,
`Unexpected empty commit hash for app ID "${application.id}"`,
);
}
// handle `--commit current` (and its `--commit latest` synonym)

View File

@ -20,24 +20,17 @@ import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { BalenaSDK } from 'balena-sdk';
import type { BalenaSDK, Application, Organization } from 'balena-sdk';
import { ExpectedError, instanceOf } from '../errors';
import { isV13 } from '../utils/version';
import { RegistrySecrets } from 'resin-multibuild';
import { lowercaseIfSlug } from '../utils/normalization';
import {
applyReleaseTagKeysAndValues,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
enum BuildTarget {
Cloud,
Device,
}
interface ArgsDef {
fleetOrDevice: string;
}
interface FlagsDef {
source: string;
emulated: boolean;
@ -46,23 +39,29 @@ interface FlagsDef {
pull: boolean;
'noparent-check': boolean;
'registry-secrets'?: string;
gitignore?: boolean;
nogitignore?: boolean;
nolive: boolean;
detached: boolean;
service?: string[];
system: boolean;
env?: string[];
'convert-eol'?: boolean;
'noconvert-eol': boolean;
'multi-dockerignore': boolean;
'release-tag'?: string[];
draft: boolean;
help: void;
}
interface ArgsDef {
applicationOrDevice: string;
}
export default class PushCmd extends Command {
public static description = stripIndent`
Build release images on balenaCloud servers or on a local mode device.
Start a build on the remote balenaCloud build servers, or a local mode device.
Build release images on balenaCloud servers or on a local mode device.
Start a build on the remote balenaCloud build servers, or a local mode device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
@ -89,16 +88,16 @@ export default class PushCmd extends Command {
${dockerignoreHelp.split('\n').join('\n\t\t')}
Note: the --service and --env flags must come after the fleetOrDevice
Note: the --service and --env flags must come after the applicationOrDevice
parameter, as per examples.
`;
public static examples = [
'$ balena push myFleet',
'$ balena push myFleet --source <source directory>',
'$ balena push myFleet -s <source directory>',
'$ balena push myFleet --release-tag key1 "" key2 "value2 with spaces"',
'$ balena push myorg/myfleet',
'$ balena push myApp',
'$ balena push myApp --source <source directory>',
'$ balena push myApp -s <source directory>',
'$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"',
'$ balena push myorg/myapp',
'',
'$ balena push 10.0.0.1',
'$ balena push 10.0.0.1 --source <source directory>',
@ -112,15 +111,15 @@ export default class PushCmd extends Command {
public static args = [
{
name: 'fleetOrDevice',
name: 'applicationOrDevice',
description:
'fleet name or slug, or local device IP address or ".local" hostname',
'application name or slug, or local device IP address or hostname',
required: true,
parse: lowercaseIfSlug,
},
];
public static usage = 'push <fleetOrDevice>';
public static usage = 'push <applicationOrDevice>';
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
@ -132,13 +131,11 @@ export default class PushCmd extends Command {
}),
emulated: flags.boolean({
description: stripIndent`
Don't use the faster, native balenaCloud ARM builders; force slower QEMU ARM
emulation on Intel x86-64 builders. This flag is sometimes used to investigate
suspected issues with the balenaCloud backend.`,
Don't use native ARM servers; force QEMU ARM emulation on Intel x86-64
servers during the image build (balenaCloud).`,
char: 'e',
default: false,
}),
// TODO: docker-compose naming
dockerfile: flags.string({
description:
'Alternative Dockerfile name/path, relative to the source folder',
@ -186,7 +183,7 @@ export default class PushCmd extends Command {
When pushing to the cloud, this option will cause the build to start, then
return execution back to the shell, with the status and release ID (if
applicable). When pushing to a local mode device, this option will cause
the command to not tail logs when the build has completed.`,
the command to not tail application logs when the build has completed.`,
char: 'd',
default: false,
}),
@ -215,6 +212,16 @@ export default class PushCmd extends Command {
`,
multiple: true,
}),
...(isV13()
? {}
: {
'convert-eol': flags.boolean({
description: 'No-op and deprecated since balena CLI v12.0.0',
char: 'l',
hidden: true,
default: false,
}),
}),
'noconvert-eol': flags.boolean({
description: `Don't convert line endings from CRLF (Windows format) to LF (Unix format).`,
default: false,
@ -224,24 +231,37 @@ export default class PushCmd extends Command {
'Have each service use its own .dockerignore file. See "balena help push".',
char: 'm',
default: false,
exclusive: ['gitignore'],
}),
...(isV13()
? {}
: {
nogitignore: flags.boolean({
description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
char: 'G',
hidden: true,
default: false,
}),
}),
gitignore: flags.boolean({
description: stripIndent`
Consider .gitignore files in addition to the .dockerignore file. This reverts
to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted.`,
char: 'g',
default: false,
exclusive: ['multi-dockerignore'],
}),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the image build is successful (balenaCloud only). Multiple
Set release tags if the push to a cloud application is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
exclusive: ['detached'],
}),
draft: flags.boolean({
description: stripIndent`
Instruct the builder to create the release as a draft. Draft releases are ignored
by the 'track latest' release policy but can be used through release pinning.
Draft releases can be marked as final through the API. Releases are created
as final by default unless this option is given.`,
default: false,
}),
help: cf.help,
};
@ -267,12 +287,14 @@ export default class PushCmd extends Command {
},
);
switch (await this.getBuildTarget(params.fleetOrDevice)) {
switch (await this.getBuildTarget(params.applicationOrDevice)) {
case BuildTarget.Cloud:
logger.logDebug(`Pushing to cloud for fleet: ${params.fleetOrDevice}`);
logger.logDebug(
`Pushing to cloud for application: ${params.applicationOrDevice}`,
);
await this.pushToCloud(
params.fleetOrDevice,
params.applicationOrDevice,
options,
sdk,
dockerfilePath,
@ -281,9 +303,11 @@ export default class PushCmd extends Command {
break;
case BuildTarget.Device:
logger.logDebug(`Pushing to local device: ${params.fleetOrDevice}`);
logger.logDebug(
`Pushing to local device: ${params.applicationOrDevice}`,
);
await this.pushToDevice(
params.fleetOrDevice,
params.applicationOrDevice,
options,
dockerfilePath,
registrySecrets,
@ -315,9 +339,23 @@ export default class PushCmd extends Command {
'is only valid when pushing to a local mode device',
);
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
const releaseTags = options['release-tag'] ?? [];
const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0);
const releaseTagValues = releaseTags.filter((_v, i) => i % 2 === 1);
releaseTagKeys.forEach((key) => {
if (key === '') {
throw new ExpectedError(`Error: --release-tag keys cannot be empty`);
}
if (/\s/.test(key)) {
throw new ExpectedError(
`Error: --release-tag keys cannot contain whitespaces`,
);
}
});
if (releaseTagKeys.length !== releaseTagValues.length) {
releaseTagValues.push('');
}
await Command.checkLoggedIn();
const [token, baseUrl] = await Promise.all([
@ -325,9 +363,16 @@ export default class PushCmd extends Command {
sdk.settings.get('balenaUrl'),
]);
const application = await getApplication(sdk, appNameOrSlug, {
$select: ['app_name', 'slug'],
});
const application = (await getApplication(sdk, appNameOrSlug, {
$expand: {
organization: {
$select: ['handle'],
},
},
$select: ['app_name'],
})) as Application & {
organization: [Organization];
};
const opts = {
dockerfilePath,
@ -337,23 +382,27 @@ export default class PushCmd extends Command {
registrySecrets,
headless: options.detached,
convertEol: !options['noconvert-eol'],
isDraft: options.draft,
};
const args = {
appSlug: application.slug,
app: application.app_name,
owner: application.organization[0].handle,
source: options.source,
auth: token,
baseUrl,
nogitignore: !options.gitignore,
sdk,
opts,
};
const releaseId = await remote.startRemoteBuild(args);
if (releaseId) {
await applyReleaseTagKeysAndValues(
sdk,
releaseId,
releaseTagKeys,
releaseTagValues,
// Above we have checked that releaseTagKeys and releaseTagValues are of the same size
const _ = await import('lodash');
await Promise.all(
(_.zip(releaseTagKeys, releaseTagValues) as Array<
[string, string]
>).map(async ([key, value]) => {
await sdk.models.release.tags.set(releaseId, key, value);
}),
);
} else if (releaseTagKeys.length > 0) {
throw new Error(stripIndent`
@ -369,11 +418,11 @@ export default class PushCmd extends Command {
registrySecrets: RegistrySecrets,
) {
// Check for invalid options
const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag', 'draft'];
const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag'];
this.checkInvalidOptions(
remoteOnlyOptions,
options,
'is only valid when pushing to a fleet',
'is only valid when pushing to an application',
);
const deviceDeploy = await import('../utils/device/deploy');
@ -387,6 +436,7 @@ export default class PushCmd extends Command {
multiDockerignore: options['multi-dockerignore'],
nocache: options.nocache,
pull: options.pull,
nogitignore: !options.gitignore,
noParentCheck: options['noparent-check'],
nolive: options.nolive,
detached: options.detached,

View File

@ -1,86 +0,0 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
}
interface ArgsDef {
commitOrId: string | number;
}
export default class ReleaseFinalizeCmd extends Command {
public static description = stripIndent`
Finalize a release.
Finalize a release. Releases can be "draft" or "final", and this command
changes a draft release into a final release. Draft releases can be created
with the \`--draft\` option of the \`balena build\` or \`balena deploy\`
commands.
Draft releases are not automatically deployed to devices tracking the latest
release. For a draft release to be deployed to a device, the device should be
explicity pinned to that release. Conversely, final releases may trigger immediate
deployment to unpinned devices (subject to a device's polling period) and, for
this reason, final releases cannot be changed back to draft status.
`;
public static examples = [
'$ balena release finalize a777f7345fe3d655c1c981aa642e5555',
'$ balena release finalize 1234567',
];
public static usage = 'release finalize <commitOrId>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static args = [
{
name: 'commitOrId',
description: 'the commit or ID of the release to finalize',
required: true,
parse: (commitOrId: string) => tryAsInteger(commitOrId),
},
];
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(ReleaseFinalizeCmd);
const balena = getBalenaSdk();
const release = await balena.models.release.get(params.commitOrId, {
$select: ['id', 'is_final'],
});
if (release.is_final) {
console.log(`Release ${params.commitOrId} is already finalized!`);
return;
}
await balena.models.release.finalize(release.id);
console.log(`Release ${params.commitOrId} finalized`);
}
}

View File

@ -1,128 +0,0 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import type * as BalenaSdk from 'balena-sdk';
import jsyaml = require('js-yaml');
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
composition?: boolean;
}
interface ArgsDef {
commitOrId: string | number;
}
export default class ReleaseCmd extends Command {
public static description = stripIndent`
Get info for a release.
`;
public static examples = [
'$ balena release a777f7345fe3d655c1c981aa642e5555',
'$ balena release 1234567',
];
public static usage = 'release <commitOrId>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
composition: flags.boolean({
default: false,
char: 'c',
description: 'Return the release composition',
}),
};
public static args = [
{
name: 'commitOrId',
description: 'the commit or ID of the release to get information',
required: true,
parse: (commitOrId: string) => tryAsInteger(commitOrId),
},
];
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ReleaseCmd,
);
const balena = getBalenaSdk();
if (options.composition) {
await this.showComposition(params.commitOrId, balena);
} else {
await this.showReleaseInfo(params.commitOrId, balena);
}
}
async showComposition(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
const release = await balena.models.release.get(commitOrId, {
$select: 'composition',
});
console.log(jsyaml.dump(release.composition));
}
async showReleaseInfo(
commitOrId: string | number,
balena: BalenaSdk.BalenaSDK,
) {
const fields: Array<keyof BalenaSdk.Release> = [
'id',
'commit',
'created_at',
'status',
'semver',
'is_final',
'build_log',
'start_timestamp',
'end_timestamp',
];
const release = await balena.models.release.get(commitOrId, {
$select: fields,
$expand: {
release_tag: {
$select: ['tag_key', 'value'],
},
},
});
const tagStr = release
.release_tag!.map((t) => `${t.tag_key}=${t.value}`)
.join('\n');
const _ = await import('lodash');
const values = _.mapValues(
release,
(val) => val ?? 'N/a',
) as Dictionary<string>;
values['tags'] = tagStr;
console.log(getVisuals().table.vertical(values, [...fields, 'tags']));
}
}

View File

@ -1,87 +0,0 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationNameNote } from '../utils/messages';
import type * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
fleet: string;
}
export default class ReleasesCmd extends Command {
public static description = stripIndent`
List all releases of a fleet.
List all releases of the given fleet.
${applicationNameNote.split('\n').join('\n\t\t')}
`;
public static examples = ['$ balena releases myorg/myfleet'];
public static usage = 'releases <fleet>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static args = [
{
name: 'fleet',
description: 'fleet name or slug (preferred)',
required: true,
},
];
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(ReleasesCmd);
const fields: Array<keyof BalenaSdk.Release> = [
'id',
'commit',
'created_at',
'status',
'semver',
'is_final',
];
const balena = getBalenaSdk();
const { getFleetSlug } = await import('../utils/sdk');
const releases = await balena.models.release.getAllByApplication(
await getFleetSlug(balena, params.fleet),
{ $select: fields },
);
const _ = await import('lodash');
console.log(
getVisuals().table.horizontal(
releases.map((rel) => _.mapValues(rel, (val) => val ?? 'N/a')),
fields,
),
);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2017-2021 Balena Ltd.
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -68,7 +68,6 @@ export default class ScanCmd extends Command {
public static primary = true;
public static root = true;
public static offlineCompatible = true;
public async run() {
const _ = await import('lodash');
@ -88,17 +87,18 @@ export default class ScanCmd extends Command {
const ux = getCliUx();
ux.action.start('Scanning for local balenaOS devices');
const localDevices: LocalBalenaOsDevice[] =
await discover.discoverLocalBalenaOsDevices(discoverTimeout);
const localDevices: LocalBalenaOsDevice[] = await discover.discoverLocalBalenaOsDevices(
discoverTimeout,
);
const engineReachableDevices: boolean[] = await Promise.all(
localDevices.map(async ({ address }: { address: string }) => {
const docker = await dockerUtils.createClient({
const docker = dockerUtils.createClient({
host: address,
port: dockerPort,
timeout: dockerTimeout,
});
}) as any;
try {
await docker.ping();
await docker.pingAsync();
return true;
} catch (err) {
return false;
@ -132,14 +132,14 @@ export default class ScanCmd extends Command {
// Query devices for info
const devicesInfo = await Promise.all(
developmentDevices.map(async ({ host, address }) => {
const docker = await dockerUtils.createClient({
const docker = dockerUtils.createClient({
host: address,
port: dockerPort,
timeout: dockerTimeout,
});
}) as any;
const [dockerInfo, dockerVersion] = await Promise.all([
docker.info(),
docker.version(),
docker.infoAsync().catchReturn('Could not get Docker info'),
docker.versionAsync().catchReturn('Could not get Docker version'),
]);
return {
host,
@ -165,13 +165,7 @@ export default class ScanCmd extends Command {
});
}
const cmdOutput: Array<{
host: string;
address: string;
osVariant: string;
dockerInfo: any;
dockerVersion: import('dockerode').DockerVersion | undefined;
}> = [...productionDevicesInfo, ...devicesInfo];
const cmdOutput = productionDevicesInfo.concat(devicesInfo);
// Output results
if (!options.json && cmdOutput.length === 0) {

View File

@ -20,6 +20,7 @@ import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
import * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
port?: number;
@ -30,20 +31,20 @@ interface FlagsDef {
}
interface ArgsDef {
fleetOrDevice: string;
applicationOrDevice: string;
service?: string;
}
export default class SshCmd extends Command {
public static description = stripIndent`
Open a SSH prompt on a device's host OS or service container.
SSH into the host or application container of a device.
Start a shell on a local or remote device. If a service name is not provided,
a shell will be opened on the host OS.
If a fleet is provided, an interactive menu will be presented for the selection
of an online device. A shell will then be opened for the host OS or service
container of the chosen device.
If an application is provided, an interactive menu will be presented
for the selection of an online device. A shell will then be opened for the
host OS or service container of the chosen device.
For local devices, the IP address and .local domain name are supported.
If the device is referenced by IP or \`.local\` address, the connection
@ -63,7 +64,7 @@ export default class SshCmd extends Command {
`;
public static examples = [
'$ balena ssh MyFleet',
'$ balena ssh MyApp',
'$ balena ssh f49cefd',
'$ balena ssh f49cefd my-service',
'$ balena ssh f49cefd --port <port>',
@ -75,9 +76,9 @@ export default class SshCmd extends Command {
public static args = [
{
name: 'fleetOrDevice',
name: 'applicationOrDevice',
description:
'fleet name/slug/id, device uuid, or address of local device',
'application name/slug/id, device uuid, or address of local device',
required: true,
},
{
@ -87,7 +88,7 @@ export default class SshCmd extends Command {
},
];
public static usage = 'ssh <fleetOrDevice> [service]';
public static usage = 'ssh <applicationOrDevice> [service]';
public static flags: flags.Input<FlagsDef> = {
port: flags.integer({
@ -116,7 +117,6 @@ export default class SshCmd extends Command {
};
public static primary = true;
public static offlineCompatible = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
@ -124,11 +124,11 @@ export default class SshCmd extends Command {
);
// Local connection
if (validateLocalHostnameOrIp(params.fleetOrDevice)) {
if (validateLocalHostnameOrIp(params.applicationOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
hostname: params.fleetOrDevice,
port: options.port || 'local',
address: params.applicationOrDevice,
port: options.port,
forceTTY: options.tty,
verbose: options.verbose,
service: params.service,
@ -136,7 +136,7 @@ export default class SshCmd extends Command {
}
// Remote connection
const { getProxyConfig } = await import('../utils/helpers');
const { getProxyConfig, which } = await import('../utils/helpers');
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const sdk = getBalenaSdk();
@ -144,14 +144,18 @@ export default class SshCmd extends Command {
const useProxy = !!proxyConfig && !options.noproxy;
// this will be a tunnelled SSH connection...
await Command.checkNotUsingOfflineMode();
await Command.checkLoggedIn();
const deviceUuid = await getOnlineTargetDeviceUuid(
sdk,
params.fleetOrDevice,
params.applicationOrDevice,
);
const { which } = await import('../utils/which');
const device = await sdk.models.device.get(deviceUuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined,
@ -202,15 +206,19 @@ export default class SshCmd extends Command {
// that we know exists and is accessible
let containerId: string | undefined;
if (params.service != null) {
const { getContainerIdForService } = await import('../utils/device/ssh');
containerId = await getContainerIdForService({
containerId = await this.getContainerId(
sdk,
deviceUuid,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
service: params.service,
username: username!,
});
params.service,
{
port: options.port,
proxyCommand,
proxyUrl: proxyUrl || '',
username: username!,
},
supervisorVersion,
deviceId,
);
}
let accessCommand: string;
@ -219,14 +227,158 @@ export default class SshCmd extends Command {
} else {
accessCommand = `host ${deviceUuid}`;
}
const { runRemoteCommand } = await import('../utils/ssh');
await runRemoteCommand({
cmd: accessCommand,
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
username,
const command = this.generateVpnSshCommand({
uuid: deviceUuid,
command: accessCommand,
verbose: options.verbose,
port: options.port,
proxyCommand,
proxyUrl: proxyUrl || '',
username: username!,
});
const { spawnSshAndThrowOnError } = await import('../utils/ssh');
return spawnSshAndThrowOnError(command);
}
async getContainerId(
sdk: BalenaSdk.BalenaSDK,
uuid: string,
serviceName: string,
sshOpts: {
port?: number;
proxyCommand?: string[];
proxyUrl: string;
username: string;
},
version?: string,
id?: number,
): Promise<string> {
const semver = await import('balena-semver');
if (version == null || id == null) {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version'],
});
version = device.supervisor_version;
id = device.id;
}
let containerId: string | undefined;
if (semver.gte(version, '8.6.0')) {
const apiUrl = await sdk.settings.get('apiUrl');
// TODO: Move this into the SDKs device model
const request = await sdk.request.send({
method: 'POST',
url: '/supervisor/v2/containerId',
baseUrl: apiUrl,
body: {
method: 'GET',
deviceId: id,
},
});
if (request.status !== 200) {
throw new Error(
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
);
}
const body = request.body;
if (body.status !== 'success') {
throw new Error(
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
);
}
containerId = body.services[serviceName];
} else {
console.error(stripIndent`
Using legacy method to detect container ID. This will be slow.
To speed up this process, please update your device to an OS
which has a supervisor version of at least v8.6.0.
`);
// We need to execute a balena ps command on the device,
// and parse the output, looking for a specific
// container
const childProcess = await import('child_process');
const { escapeRegExp } = await import('lodash');
const { which } = await import('../utils/helpers');
const { deviceContainerEngineBinary } = await import(
'../utils/device/ssh'
);
const sshBinary = await which('ssh');
const sshArgs = this.generateVpnSshCommand({
uuid,
verbose: false,
port: sshOpts.port,
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
proxyCommand: sshOpts.proxyCommand,
proxyUrl: sshOpts.proxyUrl,
username: sshOpts.username,
});
if (process.env.DEBUG) {
console.error(`[debug] [${sshBinary}, ${sshArgs.join(', ')}]`);
}
const subProcess = childProcess.spawn(sshBinary, sshArgs, {
stdio: [null, 'pipe', null],
});
const containers = await new Promise<string>((resolve, reject) => {
const output: string[] = [];
subProcess.stdout.on('data', (chunk) => output.push(chunk.toString()));
subProcess.on('close', (code: number) => {
if (code !== 0) {
reject(
new Error(
`Non-zero error code when looking for service container: ${code}`,
),
);
} else {
resolve(output.join(''));
}
});
});
const lines = containers.split('\n');
const regex = new RegExp(`\\/?${escapeRegExp(serviceName)}_\\d+_\\d+`);
for (const container of lines) {
const [cId, name] = container.split(' ');
if (regex.test(name)) {
containerId = cId;
break;
}
}
}
if (containerId == null) {
throw new Error(
`Could not find a service ${serviceName} on device ${uuid}.`,
);
}
return containerId;
}
generateVpnSshCommand(opts: {
uuid: string;
command: string;
verbose: boolean;
port?: number;
username: string;
proxyUrl: string;
proxyCommand?: string[];
}) {
return [
...(opts.verbose ? ['-vvv'] : []),
'-t',
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
...(opts.proxyCommand && opts.proxyCommand.length
? ['-o', `ProxyCommand=${opts.proxyCommand.join(' ')}`]
: []),
...(opts.port ? ['-p', opts.port.toString()] : []),
`${opts.username}@ssh.${opts.proxyUrl}`,
opts.command,
];
}
}

View File

@ -23,7 +23,7 @@ import { getBalenaSdk, getCliUx, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
interface FlagsDef {
fleet?: string;
application?: string;
device?: string;
duration?: string;
help: void;
@ -35,16 +35,16 @@ interface ArgsDef {
export default class SupportCmd extends Command {
public static description = stripIndent`
Grant or revoke support access for devices or fleets.
Grant or revoke support access for devices and applications.
Grant or revoke balena support agent access to devices or fleets
Grant or revoke balena support agent access to devices and applications
on balenaCloud. (This command does not apply to openBalena.)
Access will be automatically revoked once the specified duration has elapsed.
Duration defaults to 24h, but can be specified using --duration flag in days
or hours, e.g. '12h', '2d'.
Both --device and --fleet flags accept multiple values, specified as
Both --device and --application flags accept multiple values, specified as
a comma-separated list (with no spaces).
${applicationIdInfo.split('\n').join('\n\t\t')}
@ -52,8 +52,8 @@ export default class SupportCmd extends Command {
public static examples = [
'balena support enable --device ab346f,cd457a --duration 3d',
'balena support enable --fleet myFleet --duration 12h',
'balena support disable -f myorg/myfleet',
'balena support enable --application app3 --duration 12h',
'balena support disable -a myorg/myapp',
];
public static args = [
@ -71,10 +71,10 @@ export default class SupportCmd extends Command {
description: 'comma-separated list (no spaces) of device UUIDs',
char: 'd',
}),
fleet: {
...cf.fleet,
application: {
...cf.application,
description:
'comma-separated list (no spaces) of fleet names or slugs (preferred)',
'comma-separated list (no spaces) of application names or org/name slugs',
},
duration: flags.string({
description:
@ -97,8 +97,10 @@ export default class SupportCmd extends Command {
const enabling = params.action === 'enable';
// Validation
if (!options.device && !options.fleet) {
throw new ExpectedError('At least one device or fleet must be specified');
if (!options.device && !options.application) {
throw new ExpectedError(
'At least one device or application must be specified',
);
}
if (options.duration != null && !enabling) {
@ -113,7 +115,7 @@ export default class SupportCmd extends Command {
const expiryTs = Date.now() + this.parseDuration(duration);
const deviceUuids = options.device?.split(',') || [];
const appNames = options.fleet?.split(',') || [];
const appNames = options.application?.split(',') || [];
const enablingMessage = 'Enabling support access for';
const disablingMessage = 'Disabling support access for';
@ -130,17 +132,14 @@ export default class SupportCmd extends Command {
ux.action.stop();
}
const { getFleetSlug } = await import('../utils/sdk');
// Process applications
for (const appName of appNames) {
const slug = await getFleetSlug(balena, appName);
if (enabling) {
ux.action.start(`${enablingMessage} fleet ${slug}`);
await balena.models.application.grantSupportAccess(slug, expiryTs);
ux.action.start(`${enablingMessage} application ${appName}`);
await balena.models.application.grantSupportAccess(appName, expiryTs);
} else if (params.action === 'disable') {
ux.action.start(`${disablingMessage} fleet ${slug}`);
await balena.models.application.revokeSupportAccess(slug);
ux.action.start(`${disablingMessage} application ${appName}`);
await balena.models.application.revokeSupportAccess(appName);
}
ux.action.stop();
}

View File

@ -22,10 +22,11 @@ import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
fleet?: string;
application?: string;
device?: string;
release?: string;
help: void;
app?: string;
}
interface ArgsDef {
@ -34,16 +35,16 @@ interface ArgsDef {
export default class TagRmCmd extends Command {
public static description = stripIndent`
Remove a tag from a fleet, device or release.
Remove a tag from an application, device or release.
Remove a tag from a fleet, device or release.
Remove a tag from an application, device or release.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena tag rm myTagKey --fleet MyFleet',
'$ balena tag rm myTagKey -f myorg/myfleet',
'$ balena tag rm myTagKey --application MyApp',
'$ balena tag rm myTagKey -a myorg/myapp',
'$ balena tag rm myTagKey --device 7cf02a6',
'$ balena tag rm myTagKey --release 1234',
'$ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6',
@ -60,17 +61,21 @@ export default class TagRmCmd extends Command {
public static usage = 'tag rm <tagKey>';
public static flags: flags.Input<FlagsDef> = {
fleet: {
...cf.fleet,
exclusive: ['device', 'release'],
application: {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['fleet', 'release'],
exclusive: ['app', 'application', 'release'],
},
release: {
...cf.release,
exclusive: ['fleet', 'device'],
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
};
@ -82,20 +87,24 @@ export default class TagRmCmd extends Command {
TagRmCmd,
);
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
const balena = getBalenaSdk();
// Check user has specified one of application/device/release
if (!options.fleet && !options.device && !options.release) {
if (!options.application && !options.device && !options.release) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(TagRmCmd.missingResourceMessage);
}
const { tryAsInteger } = await import('../../utils/validation');
if (options.fleet) {
const { getFleetSlug } = await import('../../utils/sdk');
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../../utils/sdk');
return balena.models.application.tags.remove(
await getFleetSlug(balena, options.fleet),
await getTypedApplicationIdentifier(balena, options.application),
params.tagKey,
);
}
@ -121,9 +130,9 @@ export default class TagRmCmd extends Command {
protected static missingResourceMessage = stripIndent`
To remove a resource tag, you must provide exactly one of:
* A fleet, with --fleet <fleetNameOrSlug>
* A device, with --device <UUID>
* A release, with --release <ID or commit>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>
See the help page for examples:

View File

@ -22,10 +22,11 @@ import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
fleet?: string;
application?: string;
device?: string;
release?: string;
help: void;
app?: string;
}
interface ArgsDef {
@ -35,9 +36,9 @@ interface ArgsDef {
export default class TagSetCmd extends Command {
public static description = stripIndent`
Set a tag on a fleet, device or release.
Set a tag on an application, device or release.
Set a tag on a fleet, device or release.
Set a tag on an application, device or release.
You can optionally provide a value to be associated with the created
tag, as an extra argument after the tag key. If a value isn't
@ -47,9 +48,9 @@ export default class TagSetCmd extends Command {
`;
public static examples = [
'$ balena tag set mySimpleTag --fleet MyFleet',
'$ balena tag set mySimpleTag -f myorg/myfleet',
'$ balena tag set myCompositeTag myTagValue --fleet MyFleet',
'$ balena tag set mySimpleTag --application MyApp',
'$ balena tag set mySimpleTag -a myorg/myapp',
'$ balena tag set myCompositeTag myTagValue --application MyApp',
'$ balena tag set myCompositeTag myTagValue --device 7cf02a6',
'$ balena tag set myCompositeTag "my tag value with whitespaces" --device 7cf02a6',
'$ balena tag set myCompositeTag myTagValue --release 1234',
@ -73,17 +74,21 @@ export default class TagSetCmd extends Command {
public static usage = 'tag set <tagKey> [value]';
public static flags: flags.Input<FlagsDef> = {
fleet: {
...cf.fleet,
exclusive: ['device', 'release'],
application: {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['fleet', 'release'],
exclusive: ['app', 'application', 'release'],
},
release: {
...cf.release,
exclusive: ['fleet', 'device'],
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
};
@ -95,10 +100,14 @@ export default class TagSetCmd extends Command {
TagSetCmd,
);
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
const balena = getBalenaSdk();
// Check user has specified one of application/device/release
if (!options.fleet && !options.device && !options.release) {
if (!options.application && !options.device && !options.release) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(TagSetCmd.missingResourceMessage);
}
@ -107,10 +116,10 @@ export default class TagSetCmd extends Command {
const { tryAsInteger } = await import('../../utils/validation');
if (options.fleet) {
const { getFleetSlug } = await import('../../utils/sdk');
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../../utils/sdk');
return balena.models.application.tags.set(
await getFleetSlug(balena, options.fleet),
await getTypedApplicationIdentifier(balena, options.application),
params.tagKey,
params.value,
);
@ -142,9 +151,9 @@ export default class TagSetCmd extends Command {
protected static missingResourceMessage = stripIndent`
To set a resource tag, you must provide exactly one of:
* A fleet, with --fleet <fleetNameOrSlug>
* A device, with --device <UUID>
* A release, with --release <ID or commit>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>
See the help page for examples:

View File

@ -23,24 +23,26 @@ import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
interface FlagsDef {
fleet?: string;
application?: string;
device?: string;
release?: string;
help: void;
app?: string;
}
export default class TagsCmd extends Command {
public static description = stripIndent`
List all tags for a fleet, device or release.
List all tags for an application, device or release.
List all tags and their values for the specified fleet, device or release.
List all tags and their values for a particular application,
device or release.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena tags --fleet MyFleet',
'$ balena tags -f myorg/myfleet',
'$ balena tags --application MyApp',
'$ balena tags -a myorg/myapp',
'$ balena tags --device 7cf02a6',
'$ balena tags --release 1234',
'$ balena tags --release b376b0e544e9429483b656490e5b9443b4349bd6',
@ -49,17 +51,21 @@ export default class TagsCmd extends Command {
public static usage = 'tags';
public static flags: flags.Input<FlagsDef> = {
fleet: {
...cf.fleet,
exclusive: ['device', 'release'],
application: {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['fleet', 'release'],
exclusive: ['app', 'application', 'release'],
},
release: {
...cf.release,
exclusive: ['fleet', 'device'],
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
};
@ -69,10 +75,14 @@ export default class TagsCmd extends Command {
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(TagsCmd);
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
const balena = getBalenaSdk();
// Check user has specified one of application/device/release
if (!options.fleet && !options.device && !options.release) {
if (!options.application && !options.device && !options.release) {
throw new ExpectedError(this.missingResourceMessage);
}
@ -80,10 +90,10 @@ export default class TagsCmd extends Command {
let tags;
if (options.fleet) {
const { getFleetSlug } = await import('../utils/sdk');
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../utils/sdk');
tags = await balena.models.application.tags.getAllByApplication(
await getFleetSlug(balena, options.fleet),
await getTypedApplicationIdentifier(balena, options.application),
);
}
if (options.device) {
@ -113,7 +123,7 @@ export default class TagsCmd extends Command {
protected missingResourceMessage = stripIndent`
To list tags for a resource, you must provide exactly one of:
* A fleet, with --fleet <fleetNameOrSlug>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>

View File

@ -24,8 +24,6 @@ import {
} from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { lowercaseIfSlug } from '../utils/normalization';
import type { Server, Socket } from 'net';
interface FlagsDef {
@ -34,37 +32,29 @@ interface FlagsDef {
}
interface ArgsDef {
deviceOrFleet: string;
deviceOrApplication: string;
}
export default class TunnelCmd extends Command {
public static description = stripIndent`
Tunnel local ports to your balenaOS device.
Use this command to open local TCP ports that tunnel to listening sockets in a
balenaOS device.
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
For example, this command could be used to expose the ssh server of a balenaOS
device (port number 22222) on the local machine, or to expose a web server
running on the device. The port numbers do not have be the same between the
device and the local machine, and multiple ports may be tunneled in a single
command line.
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
device running a web server listening on port 3000.
Port mappings are specified in the format: <remotePort>[:[localIP:]localPort]
localIP defaults to 'localhost', and localPort defaults to the specified
remotePort value.
localIP defaults to 'localhost', and localPort defaults to the specified remotePort value.
Note: the -p (--port) flag must be provided at the end of the command line,
as per examples.
You can tunnel multiple ports at any given time.
In the case of openBalena, the tunnel command in CLI v12.38.5 or later requires
openBalena v3.1.2 or later. Older CLI versions work with older openBalena
versions.
Note: Port mappings must come after the deviceOrApplication parameter, as per examples.
`;
public static examples = [
'# map remote port 22222 to localhost:22222',
'$ balena tunnel myFleet -p 22222',
'$ balena tunnel myApp -p 22222',
'',
'# map remote port 22222 to localhost:222',
'$ balena tunnel 2ead211 -p 22222:222',
@ -73,22 +63,21 @@ export default class TunnelCmd extends Command {
'$ balena tunnel 1546690 -p 22222:0.0.0.0',
'',
'# map remote port 22222 to any address on your host machine, port 222',
'$ balena tunnel myFleet -p 22222:0.0.0.0:222',
'$ balena tunnel myApp -p 22222:0.0.0.0:222',
'',
'# multiple port tunnels can be specified at any one time',
'$ balena tunnel myFleet -p 8080:3000 -p 8081:9000',
'$ balena tunnel myApp -p 8080:3000 -p 8081:9000',
];
public static args = [
{
name: 'deviceOrFleet',
description: 'device UUID or fleet name/slug/ID',
name: 'deviceOrApplication',
description: 'device uuid or application name/slug/id',
required: true,
parse: lowercaseIfSlug,
},
];
public static usage = 'tunnel <deviceOrFleet>';
public static usage = 'tunnel <deviceOrApplication>';
public static flags: flags.Input<FlagsDef> = {
port: flags.string({
@ -135,8 +124,12 @@ export default class TunnelCmd extends Command {
// Ascertain device uuid
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const uuid = await getOnlineTargetDeviceUuid(sdk, params.deviceOrFleet);
logger.logInfo(`Opening a tunnel to ${uuid}...`);
const uuid = await getOnlineTargetDeviceUuid(
sdk,
params.deviceOrApplication,
);
const device = await sdk.models.device.get(uuid);
logger.logInfo(`Opening a tunnel to ${device.uuid}...`);
const _ = await import('lodash');
const localListeners = _.chain(options.port)
@ -146,7 +139,11 @@ export default class TunnelCmd extends Command {
.map(async ({ localPort, localAddress, remotePort }) => {
try {
const { tunnelConnectionToDevice } = await import('../utils/tunnel');
const handler = await tunnelConnectionToDevice(uuid, remotePort, sdk);
const handler = await tunnelConnectionToDevice(
device.uuid,
remotePort,
sdk,
);
const { createServer } = await import('net');
const server = createServer(async (client: Socket) => {
@ -157,7 +154,7 @@ export default class TunnelCmd extends Command {
client.remotePort || 0,
client.localAddress,
client.localPort,
uuid,
device.vpn_address || '',
remotePort,
);
} catch (err) {
@ -166,7 +163,7 @@ export default class TunnelCmd extends Command {
client.remotePort || 0,
client.localAddress,
client.localPort,
uuid,
device.vpn_address || '',
remotePort,
err,
);
@ -181,15 +178,15 @@ export default class TunnelCmd extends Command {
});
logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${uuid}:${remotePort}`,
` - tunnelling ${localAddress}:${localPort} to ${device.uuid}:${remotePort}`,
);
return true;
} catch (err) {
logger.logWarn(
` - not tunnelling ${localAddress}:${localPort} to ${uuid}:${remotePort}, failed ${JSON.stringify(
err.message,
)}`,
` - not tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
);
return false;

View File

@ -38,16 +38,12 @@ export default class UtilAvailableDrivesCmd extends Command {
help: cf.help,
};
public static offlineCompatible = true;
public async run() {
this.parse<FlagsDef, {}>(UtilAvailableDrivesCmd);
const sdk = await import('etcher-sdk');
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter({
includeSystemDrives: () => false,
});
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
const scanner = new sdk.scanner.Scanner([adapter]);
await scanner.start();

View File

@ -57,8 +57,6 @@ export default class VersionCmd extends Command {
public static usage = 'version';
public static offlineCompatible = true;
public static flags: flags.Input<FlagsDef> = {
all: flags.boolean({
default: false,

View File

@ -1,234 +0,0 @@
/**
* @license
* Copyright 2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface ReleaseTimestampsByVersion {
[version: string]: string; // e.g. { '12.0.0': '2021-06-16T12:54:52.000Z' }
lastFetched: string; // ISO 8601 timestamp, e.g. '2021-06-27T16:46:10.000Z'
}
/**
* Warn about and enforce the CLI deprecation policy stated in the README
* file. In particular:
* The latest release of a major version will remain compatible with
* the backend services for at least one year from the date when the
* following major version is released. [...]
* Half way through to that period (6 months), old major versions of the
* balena CLI will start printing a deprecation warning message.
* At the end of that period, older major versions will abort with an error
* message unless the `--unsupported` flag is used.
*
* - Check for new balena-cli releases by querying the npm registry.
* - Cache results for a number of days to improve performance.
*
* For this feature's specification and planning, see (restricted access):
* https://jel.ly.fish/ed8d2395-9323-418c-bb67-d11d32a17d00
*/
export class DeprecationChecker {
readonly majorVersionFetchIntervalDays = 7;
readonly expiryDays = 365;
readonly deprecationDays = Math.ceil(this.expiryDays / 2);
readonly msInDay = 24 * 60 * 60 * 1000; // milliseconds in a day
readonly debugPrefix = 'Deprecation check';
readonly cacheFile = 'cachedReleaseTimestamps';
readonly now = new Date().getTime();
private initialized = false;
storage: ReturnType<typeof import('balena-settings-storage')>;
cachedTimestamps: ReleaseTimestampsByVersion;
nextMajorVersion: string; // semver without the 'v' prefix
constructor(protected currentVersion: string) {
const semver = require('semver') as typeof import('semver');
const major = semver.major(this.currentVersion, { loose: true });
this.nextMajorVersion = `${major + 1}.0.0`;
}
public async init() {
if (this.initialized) {
return;
}
this.initialized = true;
const settings = await import('balena-settings-client');
const getStorage = await import('balena-settings-storage');
const dataDirectory = settings.get<string>('dataDirectory');
this.storage = getStorage({ dataDirectory });
let stored: ReleaseTimestampsByVersion | undefined;
try {
stored = (await this.storage.get(
this.cacheFile,
)) as ReleaseTimestampsByVersion;
} catch {
// ignore
}
this.cachedTimestamps = {
...stored,
// '1970-01-01T00:00:00.000Z' is new Date(0).toISOString()
lastFetched: stored?.lastFetched || '1970-01-01T00:00:00.000Z',
};
}
/**
* Get NPM registry URL to retrieve the package.json file for a given version.
* @param version Semver without 'v' prefix, e.g. '12.0.0.'
*/
protected getNpmUrl(version: string) {
return `http://registry.npmjs.org/balena-cli/${version}`;
}
/**
* Query the npm registry (HTTP request) for a given balena-cli version.
*
* @param version semver version without the 'v' prefix, e.g. '13.0.0'
* @returns `undefined` if the request status code is 404 (version not
* published), otherwise a publishedAt date in ISO 8601 format, e.g.
* '2021-06-27T16:46:10.000Z'.
*/
protected async fetchPublishedTimestampForVersion(
version: string,
): Promise<string | undefined> {
const { default: got } = await import('got');
const url = this.getNpmUrl(version);
let response: import('got').Response<Dictionary<any>> | undefined;
try {
response = await got(url, {
responseType: 'json',
retry: 0,
timeout: 4000,
});
} catch (e) {
// 404 is expected if `version` hasn't been published yet
if (e.response?.statusCode !== 404) {
throw new Error(`Failed to query "${url}":\n${e}`);
}
}
// response.body looks like a package.json file, plus possibly a
// `versionist.publishedAt` field added by `github.com/product-os/versionist`
const publishedAt: string | undefined =
response?.body?.versionist?.publishedAt;
if (!publishedAt && process.env.DEBUG) {
console.error(`\
[debug] ${this.debugPrefix}: balena CLI next major version "${this.nextMajorVersion}" not released, \
or release date not available`);
}
return publishedAt; // ISO 8601, e.g. '2021-06-27T16:46:10.000Z'
}
/**
* Check if we already know (cached value) when the next major version
* was released. If we don't know, check how long ago the npm registry
* was last fetched, and fetch again if it has been longer than
* `majorVersionFetchIntervalDays`.
*/
public async checkForNewReleasesIfNeeded() {
if (process.env.BALENARC_UNSUPPORTED) {
return; // for the benefit of code testing
}
await this.init();
if (this.cachedTimestamps[this.nextMajorVersion]) {
// A cached value exists: no need to check the npm registry
return;
}
const lastFetched = new Date(this.cachedTimestamps.lastFetched).getTime();
const daysSinceLastFetch = (this.now - lastFetched) / this.msInDay;
if (daysSinceLastFetch < this.majorVersionFetchIntervalDays) {
if (process.env.DEBUG) {
// toFixed(5) results in a precision of ~1 second
const days = daysSinceLastFetch.toFixed(5);
console.error(`\
[debug] ${this.debugPrefix}: ${days} days since last npm registry query for next major version release date.
[debug] Will not query the registry again until at least ${this.majorVersionFetchIntervalDays} days have passed.`);
}
return;
}
if (process.env.DEBUG) {
console.error(`\
[debug] ${
this.debugPrefix
}: Cache miss for the balena CLI next major version release date.
[debug] Will query ${this.getNpmUrl(this.nextMajorVersion)}`);
}
try {
const publishedAt = await this.fetchPublishedTimestampForVersion(
this.nextMajorVersion,
);
if (publishedAt) {
this.cachedTimestamps[this.nextMajorVersion] = publishedAt;
}
} catch (e) {
if (process.env.DEBUG) {
console.error(`[debug] ${this.debugPrefix}: ${e}`);
}
}
// Refresh `lastFetched` regardless of whether or not the request to the npm
// registry was successful. Will try again after `majorVersionFetchIntervalDays`.
this.cachedTimestamps.lastFetched = new Date(this.now).toISOString();
await this.storage.set(this.cacheFile, this.cachedTimestamps);
}
/**
* Use previously cached data (local cache only, fast execution) to check
* whether this version of the CLI is deprecated as per deprecation policy,
* in which case warn about it and conditionally throw an error.
*/
public async warnAndAbortIfDeprecated() {
if (process.env.BALENARC_UNSUPPORTED) {
return; // for the benefit of code testing
}
await this.init();
const nextMajorDateStr = this.cachedTimestamps[this.nextMajorVersion];
if (!nextMajorDateStr) {
return;
}
const nextMajorDate = new Date(nextMajorDateStr).getTime();
const daysElapsed = Math.trunc((this.now - nextMajorDate) / this.msInDay);
if (daysElapsed > this.expiryDays) {
const { ExpectedError } = await import('./errors');
throw new ExpectedError(this.getExpiryMsg(daysElapsed));
} else if (daysElapsed > this.deprecationDays && process.stderr.isTTY) {
console.error(this.getDeprecationMsg(daysElapsed));
}
}
/** Separate function for the benefit of code testing */
getDeprecationMsg(daysElapsed: number) {
const { warnify } =
require('./utils/messages') as typeof import('./utils/messages');
return warnify(`\
CLI version ${this.nextMajorVersion} was released ${daysElapsed} days ago: please upgrade.
This version of the balena CLI (${this.currentVersion}) will exit with an error
message after ${this.expiryDays} days from the release of version ${this.nextMajorVersion},
as per deprecation policy: https://git.io/JRHUW#deprecation-policy
The --unsupported flag may be used to bypass this deprecation check and
allow the CLI to keep working beyond the deprecation period. However,
note that the balenaCloud or openBalena backends may be updated in a way
that is no longer compatible with this version.`);
}
/** Separate function the benefit of code testing */
getExpiryMsg(daysElapsed: number) {
return `
This version of the balena CLI (${this.currentVersion}) has expired: please upgrade.
${daysElapsed} days have passed since the release of CLI version ${this.nextMajorVersion}.
See deprecation policy at: https://git.io/JRHUW#deprecation-policy
The --unsupported flag may be used to bypass this deprecation check and
continue using this version of the CLI. However, note that the balenaCloud
or openBalena backends may be updated in a way that is no longer compatible
with this CLI version.`;
}
}

View File

@ -31,8 +31,6 @@ export class NotLoggedInError extends ExpectedError {}
export class InsufficientPrivilegesError extends ExpectedError {}
export class NotAvailableInOfflineModeError extends ExpectedError {}
export class InvalidPortMappingError extends ExpectedError {
constructor(mapping: string) {
super(`'${mapping}' is not a valid port mapping.`);
@ -176,13 +174,6 @@ const messages: {
BalenaExpiredToken: () => stripIndent`
Looks like the session token has expired.
Try logging in again with the "balena login" command.`,
BalenaInvalidDeviceType: (error: Error & { deviceTypeSlug?: string }) => {
const slug = error.deviceTypeSlug ? `"${error.deviceTypeSlug}"` : 'slug';
return stripIndent`
Device type ${slug} not recognized. Perhaps misspelled?
Check available device types with "balena devices supported"`;
},
};
// TODO remove these regexes when we have a way of uniquely indentifying errors.
@ -228,12 +219,7 @@ async function sentryCaptureException(error: Error) {
}
}
export async function handleError(error: Error | string) {
// If a module has thrown a string, convert to error
if (typeof error === 'string') {
error = new Error(error);
}
export async function handleError(error: Error) {
// Set appropriate exitCode
process.exitCode =
(error as BalenaError).exitCode === 0
@ -288,3 +274,24 @@ export const printErrorMessage = function (message: string) {
export const printExpectedErrorMessage = function (message: string) {
console.error(`${message}\n`);
};
/**
* Print a friendly error message and exit the CLI with an error code, BYPASSING
* error reporting through Sentry.io's platform (raven.Raven.captureException).
* Note that lib/errors.ts provides top-level error handling code to catch any
* otherwise uncaught errors, AND to report them through Sentry.io. But many
* "expected" errors (say, a JSON parsing error in a file provided by the user)
* don't warrant reporting through Sentry.io. For such mundane errors, catch
* them and call this function.
*
* DEPRECATED: Use `throw new ExpectedError(<message>)` instead.
* If a specific process exit code x must be set, use process.exitCode = x
*/
export function exitWithExpectedError(message: string | Error): never {
if (message instanceof Error) {
({ message } = message);
}
printErrorMessage(message);
process.exit(1);
}

Some files were not shown because too many files have changed in this diff Show More