mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-06-24 18:45:07 +00:00
Compare commits
256 Commits
v12.9.8
...
fix-window
Author | SHA1 | Date | |
---|---|---|---|
ae18df6710 | |||
8101ab38a6 | |||
0bae6546f2 | |||
40ab27df26 | |||
7d5a64f59a | |||
8115d156df | |||
08fc1a3924 | |||
950d173d27 | |||
ac49246141 | |||
0689074dd7 | |||
ee79c87723 | |||
9dc9556619 | |||
2f9212d622 | |||
2bf59530c4 | |||
a4fd7d6118 | |||
65f053dd6e | |||
8137b79078 | |||
e9b5773bcb | |||
4768f76385 | |||
d6b3249274 | |||
02a5466746 | |||
0831e5fa17 | |||
4681d901f8 | |||
6a55613199 | |||
893a39e891 | |||
fa4f91e08d | |||
54dc37dbd3 | |||
1b0c14feab | |||
20e0810d2a | |||
edc2e77ddd | |||
7da9a800cc | |||
2ba4405452 | |||
e7ebf1ad12 | |||
46249e319b | |||
fcd0932df8 | |||
34792ecce9 | |||
1e18096873 | |||
4da1ed3a56 | |||
92b8741288 | |||
6b4c28a026 | |||
849fc24158 | |||
16efb9748f | |||
9d177609f5 | |||
826b0659d6 | |||
46d7d1d068 | |||
47fcffe368 | |||
bb7cd7ac62 | |||
a83f6c95df | |||
7f000ee8c3 | |||
e5e7bb4757 | |||
37e6bd4b5c | |||
c48564e85a | |||
8460dac066 | |||
64ffcfdd91 | |||
077e25ebc4 | |||
709f009f9b | |||
116ab1fbc1 | |||
260a30532a | |||
7534042519 | |||
6b208ec2ab | |||
099d755900 | |||
3199f15662 | |||
4c8dc29946 | |||
2b22fb89f1 | |||
cf7d9246e5 | |||
0d3106af0e | |||
478b5dd363 | |||
0708608c7e | |||
c245dc70c2 | |||
4373ba7a5d | |||
2cc8d15c05 | |||
592efd0a2e | |||
31123d28f0 | |||
9b6ffecaba | |||
d0e4fa0e59 | |||
cf376316bc | |||
8f0f3bda29 | |||
c33409adb0 | |||
873eb1fc59 | |||
af70f16a9b | |||
e8d757ca28 | |||
63d3402924 | |||
8a506bc4c0 | |||
a14d89fe10 | |||
29ed0a232d | |||
8978221866 | |||
2974c203b5 | |||
c85acbd90b | |||
8a808e25d0 | |||
75687f51ac | |||
eddbdfe0dc | |||
d8acc3f814 | |||
fc8be3d8dc | |||
0ee02a4d73 | |||
568fcb9759 | |||
6133bb2096 | |||
48076464da | |||
1acf342fb0 | |||
340ca6577b | |||
0a8b3ce4e4 | |||
65c01ac172 | |||
4c9a22aba7 | |||
889fafcffc | |||
719cc2e4c9 | |||
e484701276 | |||
b1897a512d | |||
f98c25eaee | |||
b9c3b57b85 | |||
8aff330516 | |||
abdaf0043f | |||
960cb3098d | |||
e907f12445 | |||
799e0f9dea | |||
c389f41006 | |||
74ca5207ad | |||
3706db2436 | |||
6ec0b4a3bd | |||
e65caed64e | |||
b180eb7b73 | |||
9805854eab | |||
00c956394d | |||
b3510f205f | |||
e755d9f03f | |||
f9224b05af | |||
ece4d88bfd | |||
0dd7c33237 | |||
cd20f1765e | |||
0ca1faba09 | |||
9f8569e33f | |||
d7007721a7 | |||
f9f1863fdb | |||
93e18bea27 | |||
73f49765ec | |||
3a508dc397 | |||
bd5bf0135a | |||
e0c65bdef8 | |||
b9d90b9e38 | |||
d910319ba5 | |||
5e5a2c1c85 | |||
238c371ade | |||
504877c232 | |||
bdcf58471f | |||
46b9c586a6 | |||
273ea5ce4d | |||
d56fec6e36 | |||
cd81ff005f | |||
dee216eeaa | |||
d1539f405a | |||
d131fb4fa8 | |||
a0380848a0 | |||
ffa8e245ba | |||
8631e22686 | |||
f0bd3a38db | |||
88569066b5 | |||
c20bbe658b | |||
ac0ce8f702 | |||
42c6e1010f | |||
1f4554abe8 | |||
4e457da5a9 | |||
2e1570149d | |||
c647989054 | |||
44bd667648 | |||
2d042ee116 | |||
787966a0b6 | |||
a59d85e833 | |||
d0616acf1b | |||
d21a18f353 | |||
7d3dbc2c0b | |||
529b98552c | |||
99a478ee39 | |||
fb879d3020 | |||
4fb4cce842 | |||
f772957d29 | |||
fd9520224c | |||
c1afaa6cf3 | |||
8cb413c1c9 | |||
e96fca551e | |||
edb3ea53fb | |||
358a909214 | |||
eb74ca631a | |||
64ebebb121 | |||
abc62404ab | |||
af1c4b0d03 | |||
830e1f801d | |||
59c398fbf0 | |||
d7f49d2442 | |||
34597f629d | |||
3fa7eec8a9 | |||
1ee12b70bc | |||
ca7b1ae084 | |||
936d3cb62a | |||
230677e5e8 | |||
025f817eb6 | |||
54cceb688f | |||
648a73fd91 | |||
3691ae148e | |||
4496bc88f5 | |||
afded27692 | |||
c1a5718364 | |||
e021ad9af6 | |||
5c8a5165e0 | |||
71ff73c641 | |||
c35472e94d | |||
511bb05cb9 | |||
53b2b54b23 | |||
e7f753007f | |||
0afaf8502f | |||
3272b55dd9 | |||
4c664167f6 | |||
604c182e2c | |||
60593a77ac | |||
497c8cd49b | |||
d348d9f71f | |||
d6651fdd7e | |||
e1c42405a1 | |||
bf22d9eaa8 | |||
88523a2887 | |||
e8eb031253 | |||
120c82d657 | |||
cb2e60d5af | |||
62dfae371c | |||
eaf220b64f | |||
9804dd3c33 | |||
94f3825119 | |||
abde3cf48a | |||
efb488f81a | |||
6ca7c34e57 | |||
3f084366db | |||
9f98529e56 | |||
bab98df87b | |||
4d9affd030 | |||
15b536a3b2 | |||
505acc19db | |||
1d566a72ca | |||
0337e284a6 | |||
74c6f8a627 | |||
7aa1708f46 | |||
32a21684e8 | |||
a52a623fdf | |||
b63e31e255 | |||
fec01977c7 | |||
cf894d98a5 | |||
d18f25cb9c | |||
4cdff9694e | |||
304ade9772 | |||
0865633020 | |||
ddb87f403d | |||
8047779c0c | |||
9da7f03b2a | |||
9aacb7ec56 | |||
41e7ba12ff | |||
10decc785d | |||
47e9d39c6f | |||
0eb4e6d770 | |||
492b877d02 | |||
09b8cc495c |
3
.gitattributes
vendored
3
.gitattributes
vendored
@ -10,6 +10,3 @@ 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
|
||||
|
||||
# Prevent auto merging of the npm-shrinkwrap.json file: see notes in CONTRIBUTING.md
|
||||
/npm-shrinkwrap.json merge=binary
|
||||
|
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@ -11,8 +11,8 @@ community can both contribute and benefit from the answers.*
|
||||
*Please also check that this issue is not a duplicate. If there is another issue describing
|
||||
the same problem or feature please add comments to the existing issue.*
|
||||
|
||||
*Thank you for your time and effort creating the issue report, and helping us improve the
|
||||
balena CLI!*
|
||||
*Thank you for your time and effort creating the issue report, and helping us improve
|
||||
the balena CLI!*
|
||||
|
||||
---
|
||||
|
||||
@ -64,6 +64,7 @@ fixed it.
|
||||
# Specifications
|
||||
|
||||
- **balena CLI version:** e.g. 1.2.3 (output of the `"balena version -a"` command)
|
||||
- **Cloud backend: openBalena or balenaCloud?** If unsure, it will be balenaCloud
|
||||
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
|
||||
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
|
||||
- **Install method:** npm or zip package or executable installer
|
||||
|
6
.mocharc-standalone.js
Normal file
6
.mocharc-standalone.js
Normal file
@ -0,0 +1,6 @@
|
||||
const commonConfig = require('./.mocharc.js');
|
||||
|
||||
module.exports = {
|
||||
...commonConfig,
|
||||
spec: ['tests/auth/*.spec.ts', 'tests/commands/**/*.spec.ts'],
|
||||
};
|
8
.mocharc.js
Normal file
8
.mocharc.js
Normal file
@ -0,0 +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,
|
||||
spec: 'tests/**/*.spec.ts',
|
||||
};
|
21
.resinci.yml
21
.resinci.yml
@ -2,30 +2,19 @@
|
||||
npm:
|
||||
platforms:
|
||||
- name: linux
|
||||
os: alpine
|
||||
os: ubuntu
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "10"
|
||||
- "12"
|
||||
- "14"
|
||||
- name: linux
|
||||
os: alpine
|
||||
architecture: x86
|
||||
node_versions:
|
||||
- "10"
|
||||
- name: darwin
|
||||
os: macos
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "10"
|
||||
- name: windows
|
||||
os: windows
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "10"
|
||||
- name: windows
|
||||
os: windows
|
||||
architecture: x86
|
||||
node_versions:
|
||||
- "10"
|
||||
- "12"
|
||||
- "14"
|
||||
|
||||
docker:
|
||||
publish: false
|
||||
|
File diff suppressed because it is too large
Load Diff
1146
CHANGELOG.md
1146
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
219
CONTRIBUTING.md
219
CONTRIBUTING.md
@ -2,10 +2,12 @@
|
||||
|
||||
The balena CLI is an open source project and your contribution is welcome!
|
||||
|
||||
* Install the dependencies listed in the [NPM Installation](./INSTALL.md#npm-installation)
|
||||
section of the `INSTALL.md` file. Check the section [Additional
|
||||
Dependencies](./INSTALL.md#additional-dependencies) too.
|
||||
* Clone the `balena-cli` repository, `cd` to it and run `npm install`.
|
||||
* Install the dependencies listed in the [NPM Installation
|
||||
section](./INSTALL-ADVANCED.md#npm-installation) section of the installation instructions. Check
|
||||
the section [Additional Dependencies](./INSTALL-ADVANCED.md#additional-dependencies) too.
|
||||
* Clone the `balena-cli` repository (or a [forked
|
||||
repo](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo),
|
||||
if you are not in the balena team), `cd` to it and run `npm install`.
|
||||
* Build the CLI with `npm run build` or `npm test`, and execute it with `./bin/balena`
|
||||
(on a Windows command prompt, you may need to run `node .\bin\balena`).
|
||||
|
||||
@ -19,44 +21,89 @@ Before opening a PR, test your changes with `npm test`. Keep compatibility in mi
|
||||
meant to run on Linux, macOS and Windows. balena CI will run test code on all three platforms, but
|
||||
this will only help if you add some test cases for your new code!
|
||||
|
||||
## ./bin/balena-dev and oclif
|
||||
## Semantic versioning, commit messages and the ChangeLog
|
||||
|
||||
When using `./bin/balena-dev` with oclif-converted commands, it is currently necessary to manually
|
||||
edit the `oclif` section of `package.json` to replace `./build` with `./lib` as follows:
|
||||
When a pull request is merged, Balena's versionbot / Continuous Integration system takes care of
|
||||
automatically creating a new CLI release on both the [npm
|
||||
registry](https://www.npmjs.com/package/balena-cli) and the GitHub [releases
|
||||
page](https://github.com/balena-io/balena-cli/releases). The release version numbering adheres to
|
||||
the [Semantic Versioning's](http://semver.org/) concept of patch, minor and major releases.
|
||||
Generally, bug fixes and documentation changes are classed as patch changes, while new features are
|
||||
classed as minor changes. If a change breaks backwards compatibility, it is a major change.
|
||||
|
||||
Change from:
|
||||
```
|
||||
"oclif": {
|
||||
"commands": "./build/actions-oclif",
|
||||
"hooks": {
|
||||
"prerun": "./build/hooks/prerun/track"
|
||||
```
|
||||
A new version entry is also automatically added to the
|
||||
[CHANGELOG.md](https://github.com/balena-io/balena-cli/blob/master/CHANGELOG.md) file when a pull
|
||||
request is merged. Each pull request corresponds to a single version / release. Each commit in the
|
||||
pull request becomes a bullet point entry in the Changelog. The Changelog file should not be
|
||||
manually edited.
|
||||
|
||||
To:
|
||||
```
|
||||
"oclif": {
|
||||
"commands": "./lib/actions-oclif",
|
||||
"hooks": {
|
||||
"prerun": "./lib/hooks/prerun/track"
|
||||
```
|
||||
To support this automation, a commit message should be structured as follows:
|
||||
|
||||
And then remember to change it back before pushing the pull request. This is obviously error prone
|
||||
and inconvenient, and improvement suggestions are welcome: is there a better solution than
|
||||
automatically editing `package.json`? It is doable, if it is what needs to be done.
|
||||
```text
|
||||
The first line becomes a bullet point in the CHANGELOG file
|
||||
|
||||
## Semantic versioning and commit messages
|
||||
Optionally, a more detailed description in one or more paragraphs.
|
||||
The detailed description can be seen with `git log`, but it is not copied
|
||||
to the CHANGELOG file.
|
||||
|
||||
The CLI version numbering adheres to [Semantic Versioning](http://semver.org/). The following
|
||||
header/row is required in the body of a commit message, and will cause the CI build to fail if absent:
|
||||
|
||||
```
|
||||
Change-type: patch|minor|major
|
||||
```
|
||||
|
||||
Version numbers and commit messages are automatically added to the `CHANGELOG.md` file by the CI
|
||||
build flow, after a pull request is merged. It should not be manually edited.
|
||||
Only the first line of the commit message is copied to the Changelog file. The `Change-type` footer
|
||||
must be preceded by a blank line, and indicates the commit's semver change type. When a PR consists
|
||||
of multiple commits, the commits may have different change type values. As a whole, the PR will
|
||||
produce a release of the "highest" change type. For example, two commits mixing patch and minor
|
||||
change types will produce a minor CLI release, while two commits mixing minor and major change
|
||||
types will produce a major CLI release.
|
||||
|
||||
## Editing documentation files (CHANGELOG, README, website...)
|
||||
The commit message is parsed / checked by versionbot with the
|
||||
[resin-commit-lint](https://github.com/balena-io-modules/resin-commit-lint#resin-commit-lint)
|
||||
package.
|
||||
|
||||
Because of the way that the Changelog file is automatically updated from commit messages, which
|
||||
become the source of "what's new" for CLI end users, we advocate "meaningful commits" and
|
||||
user-focused commit messages. A meaningful commit is one that, in isolation, introduces a fix or
|
||||
feature (or part of a fix or feature) that makes sense at the Changelog level, and which leaves the
|
||||
CLI in a non-broken state. Sometimes, in the course of preparing a single pull request, a developer
|
||||
creates several commits as a way of saving their "work in progress", which may even fail to build
|
||||
(e.g. `npm run build` fails), and which is then fixed or undone by further commits in the same PR.
|
||||
In this situation, the recommendation is to "squash" or "fixup" the work-in-progress commits into
|
||||
fewer, meaningful commits. Interactive rebase is a good tool to achieve this:
|
||||
[blog](https://thoughtbot.com/blog/git-interactive-rebase-squash-amend-rewriting-history),
|
||||
[docs](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History).
|
||||
|
||||
Mixing multiple distinct features or bug fixes in a single commit is discouraged, because the
|
||||
description will likely not fit in the single-line Changelog bullet point and also because it
|
||||
makes it harder to review the pull request (especially a large one) and harder to isolate and
|
||||
revert individual changes in case a bug is found later on. Create a separate commit for each
|
||||
feature / bug fix, or even separate pull requests.
|
||||
|
||||
If you need to catch up with changes to the master branch while working on a pull request,
|
||||
use rebase instead of merge: [docs](https://git-scm.com/book/en/v2/Git-Branching-Rebasing).
|
||||
|
||||
If `package.json` is updated for dependencies listed in the `repo.yml` file (like `balena-sdk`),
|
||||
the commit message body should also include a line in the following format:
|
||||
```
|
||||
Update balena-sdk from 12.0.0 to 12.1.0
|
||||
```
|
||||
|
||||
This allows versionbot to produce nested Changelog entries (with expandable arrows), pulling in
|
||||
commit messages from the upstream repositories. The following npm script can be used to
|
||||
automatically produce a commit with a suitable commit message:
|
||||
```
|
||||
npm run update balena-sdk ^12.1.0
|
||||
```
|
||||
|
||||
The script will create a new branch (only if `master` is currently checked out), run `npm update`
|
||||
with the given target version and commit the `package.json` and `npm-shrinkwrap.json` files. The
|
||||
script by default will set the `Change-type` to `patch` or `minor`, depending on the semver change
|
||||
of the updated dependency. A `major` change type can specified as an extra argument:
|
||||
```
|
||||
npm run update balena-sdk ^12.14.0 patch
|
||||
npm run update balena-sdk ^13.0.0 major
|
||||
```
|
||||
|
||||
## Editing documentation files (README, INSTALL, Reference website...)
|
||||
|
||||
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
|
||||
@ -65,61 +112,56 @@ Documentation page](https://www.balena.io/docs/reference/cli/).
|
||||
|
||||
The content sources for the auto generation of `doc/cli.markdown` are:
|
||||
|
||||
* Selected sections of the README file.
|
||||
* The CLI's command documentation in source code (both Capitano and oclif commands), for example:
|
||||
* `lib/actions/build.coffee`
|
||||
* `lib/actions-oclif/env/add.ts`
|
||||
* [Selected
|
||||
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
|
||||
of the README file.
|
||||
* The CLI's command documentation in source code (`lib/commands/` folder), for example:
|
||||
* `lib/commands/push.ts`
|
||||
* `lib/commands/env/add.ts`
|
||||
|
||||
The README file is manually edited, but subsections are automatically extracted for inclusion 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.
|
||||
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
|
||||
|
||||
## Windows
|
||||
|
||||
Please note that `npm run build:installer` (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 you make changes to `package.json` scripts, check
|
||||
they also run on a standard Windows Command Prompt.
|
||||
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
|
||||
|
||||
The `npm-shrinkwrap.json` file is used to control package dependencies, as documented at
|
||||
https://docs.npmjs.com/files/shrinkwrap.json.
|
||||
|
||||
While developing, the `package.json` file is often modified by, or before, running `npm install`
|
||||
in order to add, remove or modify dependencies. When `npm install` is executed, it automatically
|
||||
updates the `npm-shrinkwrap.json` file as well, **taking into account not only the `package.json`
|
||||
file but also the current state of the `node_modules` folder in your computer.**
|
||||
Changes to `npm-shrinkwrap.json` can be automatically merged by git during operations like
|
||||
`rebase`, `pull` and `cherry-pick`, but in some cases this results in suboptimal dependency
|
||||
resolution (the `node_modules` folder may end up larger than necessary, with consequences to CLI
|
||||
load time too). For this reason, the recommended way to update `npm-shrinkwrap.json` is to run
|
||||
`npm install`, possibly alongside `npm dedupe` as well. The following commands can be used to
|
||||
fix shrinkwrap issues and optimize the dependencies:
|
||||
|
||||
Meanwhile, as a text (JSON) file, `git` is capable of merging the `npm-shrinkwrap.json` file during
|
||||
operations like `rebase`, `cherry-pick` and `pull`. But git's automated merge is not the
|
||||
recommended way of updating the `npm-shrinkwrap.json` file, because it does not take into account
|
||||
duplicates or conflicts in the dependency tree, or indeed the state of the `package.json` file
|
||||
(which may have just been merged). In extreme cases, the automated merge may actually result in a
|
||||
broken installation. For these reasons, automatic merging of the `npm-shrinkwrap.json` was disabled
|
||||
through the `.gitattributes` file (the "binary merge driver" allows diff'ing but prevents automatic
|
||||
merging). Operations like `git rebase` may then result in an error like:
|
||||
|
||||
```text
|
||||
$ git rebase master
|
||||
warning: Cannot merge binary files: npm-shrinkwrap.json (HEAD vs. c34942b9... test)
|
||||
Auto-merging npm-shrinkwrap.json
|
||||
CONFLICT (content): Merge conflict in npm-shrinkwrap.json
|
||||
error: Failed to merge in the changes.
|
||||
```sh
|
||||
git checkout master -- npm-shrinkwrap.json
|
||||
rm -rf node_modules
|
||||
npm install # update npm-shrinkwrap.json to satisfy changes to package.json
|
||||
npm dedupe # deduplicate dependencies from npm-shrinkwrap.json
|
||||
npm install # re-add optional dependencies removed by dedupe
|
||||
git add npm-shrinkwrap.json # add it for committing (solve merge errors)
|
||||
```
|
||||
|
||||
Whether or not there is a merge error, the following commands are the recommended way of updating
|
||||
and committing the `npm-shrinkwrap.json` file:
|
||||
Note that `npm dedupe` should always be followed by `npm install`, as shown above, even if
|
||||
`npm install` had already been executed before `npm dedupe`.
|
||||
|
||||
```bash
|
||||
$ rm -rf node_modules # Linux / Mac
|
||||
$ rmdir /s node_modules # Windows Command Prompt
|
||||
$ npm checkout master -- npm-shrinkwrap.json # revert it to the master branch state
|
||||
$ npm install # "cleanly" update the npm-shrinkwrap.json file
|
||||
$ git add npm-shrinkwrap.json # add it for committing (solve merge errors)
|
||||
Optionally, these steps may be automated by installing the
|
||||
[npm-merge-driver](https://www.npmjs.com/package/npm-merge-driver):
|
||||
|
||||
```sh
|
||||
npx npm-merge-driver install -g
|
||||
```
|
||||
|
||||
## TypeScript and oclif
|
||||
@ -128,18 +170,12 @@ The CLI currently contains a mix of plain JavaScript and
|
||||
[TypeScript](https://www.typescriptlang.org/) code. The goal is to have all code written in
|
||||
Typescript, in order to take advantage of static typing and formal programming interfaces.
|
||||
The migration towards Typescript is taking place gradually, as part of maintenance work or
|
||||
the implementation of new features. Historically, the CLI was originally written in
|
||||
[CoffeeScript](https://coffeescript.org), but all CoffeeScript code was migrated to either
|
||||
Javascript or Typescript.
|
||||
the implementation of new features.
|
||||
|
||||
Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's
|
||||
framework, but later we decided to take advantage of [oclif](https://oclif.io/)'s features such
|
||||
as native installers for Windows, macOS and Linux, and support for custom flag parsing (for
|
||||
example, we're still battling with Capitano's behavior of dropping leading zeros of arguments that
|
||||
look like integers, such as some abbreviated UUIDs). Again, the migration is taking place
|
||||
gradually, with some CLI commands parsed by oclif and others by Capitano. A simple command line
|
||||
pre-parsing takes place in `preparser.ts`, to decide whether to route full parsing to Capitano or
|
||||
to oclif.
|
||||
Of historical interest, the CLI was originally written in [CoffeeScript](https://coffeescript.org)
|
||||
and used the [Capitano](https://github.com/balena-io/capitano) framework. All CoffeeScript code was
|
||||
migrated to either Javascript or Typescript, and Capitano was replaced with oclif. A few file or
|
||||
variable names still refer to this legacy, for example `automation/capitanodoc/capitanodoc.ts`.
|
||||
|
||||
## Programming style
|
||||
|
||||
@ -147,29 +183,6 @@ to oclif.
|
||||
reformats the code. Beyond that, we have a preference for Javascript promises over callbacks, and for
|
||||
`async/await` over `.then()`.
|
||||
|
||||
## Updating upstream dependencies
|
||||
|
||||
In order to get proper nested changelogs, when updating upstream modules that are in the repo.yml
|
||||
(like the balena-sdk), the commit body has to contain a line with the following format:
|
||||
```
|
||||
Update balena-sdk from 12.0.0 to 12.1.0
|
||||
```
|
||||
|
||||
Since this is error prone, it's suggested to use the following npm script:
|
||||
```
|
||||
npm run update balena-sdk ^12.1.0
|
||||
```
|
||||
|
||||
This will create a new branch (only if you are currently on master), run `npm update` with the
|
||||
version you provided as a target and commit the package.json & npm-shrinkwrap.json. The script by
|
||||
default will set the `Change-type` to `patch` or `minor`, depending on the semver change of the
|
||||
updated dependency, but if you need to use a different one (eg `major`) you can specify it as an
|
||||
extra argument:
|
||||
```
|
||||
npm run update balena-sdk ^12.14.0 patch
|
||||
npm run update balena-sdk ^13.0.0 major
|
||||
```
|
||||
|
||||
## Common gotchas
|
||||
|
||||
One thing that most CLI bugs have in common is the absence of test cases exercising the broken
|
||||
|
150
INSTALL-ADVANCED.md
Normal file
150
INSTALL-ADVANCED.md
Normal file
@ -0,0 +1,150 @@
|
||||
# balena CLI Advanced Installation Options
|
||||
|
||||
**These are alternative, advanced installation options. Most users would prefer the [recommended,
|
||||
streamlined installation
|
||||
instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).**
|
||||
|
||||
There are 3 options to choose from to install balena's CLI:
|
||||
|
||||
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
|
||||
traditional graphical desktop application installers.
|
||||
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
|
||||
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
|
||||
Recommended also for scripted installation in CI (continuous integration) environments.
|
||||
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
|
||||
in integrating the balena CLI in their existing projects or workflow.
|
||||
|
||||
Some specific CLI commands have a few extra installation steps: see section [Additional
|
||||
Dependencies](#additional-dependencies).
|
||||
|
||||
## Executable Installer
|
||||
|
||||
This is the recommended installation option on macOS and Windows. Follow the specific OS
|
||||
instructions:
|
||||
|
||||
* [Windows](./INSTALL-WINDOWS.md)
|
||||
* [macOS](./INSTALL-MAC.md)
|
||||
|
||||
> Note regarding WSL ([Windows Subsystem for
|
||||
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
|
||||
> If you would like to use WSL, follow the [installations instructions for
|
||||
> Linux](./INSTALL-LINUX.md) rather than Windows, as WSL consists of a Linux environment.
|
||||
|
||||
If you had previously installed the CLI using a standalone zip package, it may be a good idea to
|
||||
check your system's `PATH` environment variable for duplicate entries, as the terminal will use the
|
||||
entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package) instructions
|
||||
for how to modify the PATH variable.
|
||||
|
||||
By default, the CLI is installed to the following folders:
|
||||
|
||||
OS | Folders
|
||||
--- | ---
|
||||
Windows: | `C:\Program Files\balena-cli\`
|
||||
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
|
||||
|
||||
## Standalone Zip Package
|
||||
|
||||
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
||||
Look for a file name that ends with the word "standalone", for example:
|
||||
`balena-cli-vX.Y.Z-linux-x64-standalone.zip` ← _also for the Windows Subsystem for Linux_
|
||||
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
|
||||
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
|
||||
|
||||
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
|
||||
`balena-cli` folder.
|
||||
|
||||
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
|
||||
See instructions for:
|
||||
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
|
||||
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
|
||||
[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/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.
|
||||
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
|
||||
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
|
||||
> folders and files also present in the `balena-cli` folder.
|
||||
|
||||
To update the 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.
|
||||
|
||||
## NPM Installation
|
||||
|
||||
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 additional development tools to be installed first:
|
||||
|
||||
* [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`
|
||||
|
||||
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
|
||||
|
||||
* 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-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 (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`
|
||||
|
||||
With these dependencies in place, the balena CLI installation command is:
|
||||
|
||||
```sh
|
||||
$ npm install balena-cli -g --production --unsafe-perm
|
||||
```
|
||||
|
||||
`--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`, `preload` and `os configure` commands may require
|
||||
additional software to be installed. Check the Additional Dependencies sections for each operating
|
||||
system:
|
||||
|
||||
* [Windows](./INSTALL-WINDOWS.md#additional-dependencies)
|
||||
* [macOS](./INSTALL-MAC.md#additional-dependencies)
|
||||
* [Linux](./INSTALL-LINUX.md#additional-dependencies)
|
||||
|
||||
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.
|
||||
* To take advantage of a more powerful server (CPU, memory).
|
||||
* 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. For more details,
|
||||
check `balena help build` or the [online
|
||||
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).
|
66
INSTALL-LINUX.md
Normal file
66
INSTALL-LINUX.md
Normal file
@ -0,0 +1,66 @@
|
||||
# balena CLI Installation Instructions for Linux
|
||||
|
||||
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**
|
||||
|
||||
1. Download the latest zip file from the [latest release
|
||||
page](https://github.com/balena-io/balena-cli/releases/latest). Look for a file name that ends
|
||||
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. The extracted contents will include a
|
||||
`balena-cli` folder.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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 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
|
||||
|
||||
The `balena ssh` command requires the `ssh` command-line tool to be available. Most Linux
|
||||
distributions will already have it installed. Otherwise, `sudo apt-get install openssh-client`
|
||||
should do the trick on Debian or Ubuntu.
|
||||
|
||||
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||
command set can also be used to list and manage SSH keys: see `balena help -v`.
|
||||
|
||||
### balena scan
|
||||
|
||||
The `balena scan` command requires a multicast DNS (mDNS) service like
|
||||
[Avahi](https://en.wikipedia.org/wiki/Avahi_(software)), which is installed by default on most
|
||||
desktop Linux distributions. Otherwise, on Debian or Ubuntu, the installation command would be
|
||||
`sudo apt-get install avahi-daemon`.
|
||||
|
||||
### balena preload
|
||||
|
||||
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).
|
68
INSTALL-MAC.md
Normal file
68
INSTALL-MAC.md
Normal file
@ -0,0 +1,68 @@
|
||||
# balena CLI Installation Instructions for macOS
|
||||
|
||||
These instructions are for the recommended installation option. Advanced users may also be
|
||||
interested in [advanced installation options](./INSTALL-ADVANCED.md).
|
||||
|
||||
Selected operating system: **macOS**
|
||||
|
||||
1. Download the installer from the [latest release
|
||||
page](https://github.com/balena-io/balena-cli/releases/latest).
|
||||
Look for a file name that ends with "-installer.pkg":
|
||||
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
|
||||
|
||||
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 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 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 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
|
||||
|
||||
The `balena ssh` command requires the `ssh` command-line tool to be available. To check whether
|
||||
it is already installed, run `ssh` on a Terminal window. If it is not yet installed, the options
|
||||
include:
|
||||
|
||||
* Download the Xcode Command Line Tools from https://developer.apple.com/downloads
|
||||
* Or, if you have Xcode installed, open Xcode, choose Preferences → General → Downloads →
|
||||
Components → Command Line Tools → Install.
|
||||
* Or, install [Homebrew](https://brew.sh/), then `brew install openssh`
|
||||
|
||||
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||
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, 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 dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
|
||||
present workaround is to either:
|
||||
|
||||
* 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.
|
||||
|
||||
Long term, we are working on replacing AUFS with overlay2 for the affected device types.
|
82
INSTALL-WINDOWS.md
Normal file
82
INSTALL-WINDOWS.md
Normal file
@ -0,0 +1,82 @@
|
||||
# balena CLI Installation Instructions for Windows
|
||||
|
||||
These instructions are for the recommended installation option. Advanced users may also be
|
||||
interested in [advanced installation options](./INSTALL-ADVANCED.md).
|
||||
|
||||
Selected operating system: **Windows**
|
||||
|
||||
1. Download the installer from the [latest release
|
||||
page](https://github.com/balena-io/balena-cli/releases/latest).
|
||||
Look for a file name that ends with "-installer.exe":
|
||||
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
|
||||
|
||||
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 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`, `preload` and `os configure` 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 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
|
||||
|
||||
The `balena ssh` command requires the `ssh` command-line tool to be available. Microsoft started
|
||||
distributing an SSH client with Windows 10, which is automatically installed through Windows
|
||||
Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It
|
||||
can also be [manually
|
||||
installed](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)
|
||||
if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd
|
||||
parties.
|
||||
|
||||
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
|
||||
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
|
||||
command set can also be used to list and manage SSH keys: see `balena help -v`.
|
||||
|
||||
### balena scan
|
||||
|
||||
The `balena scan` command requires a multicast DNS (mDNS) service like Apple's Bonjour.
|
||||
Many Windows machines will already have this service installed, as it is bundled in popular
|
||||
applications such as Skype (Wikipedia lists [several others](https://en.wikipedia.org/wiki/Bonjour_(software))).
|
||||
Otherwise, Bonjour for Windows can be downloaded and installed from: https://support.apple.com/kb/DL999
|
||||
|
||||
### balena preload
|
||||
|
||||
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 dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
|
||||
present workaround is to either:
|
||||
|
||||
* 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.
|
||||
|
||||
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).
|
235
INSTALL.md
235
INSTALL.md
@ -1,231 +1,12 @@
|
||||
# balena CLI Installation Instructions
|
||||
|
||||
There are 3 options to choose from to install balena's CLI:
|
||||
Please select your operating system:
|
||||
|
||||
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
|
||||
traditional graphical desktop application installers.
|
||||
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
|
||||
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
|
||||
Recommended also for scripted installation in CI (continuous integration) environments.
|
||||
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
|
||||
in integrating the balena CLI in their existing projects or workflow.
|
||||
* [Windows](./INSTALL-WINDOWS.md)
|
||||
* [macOS](./INSTALL-MAC.md)
|
||||
* [Linux](./INSTALL-LINUX.md)
|
||||
|
||||
Some specific CLI commands have a few extra installation steps: see section [Additional
|
||||
Dependencies](#additional-dependencies).
|
||||
|
||||
> **Windows users:**
|
||||
> * There is a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg) for installing
|
||||
> and getting started with the balena CLI on Windows. (The video uses the standalone zip package
|
||||
> option.)
|
||||
> * If you are using Microsoft's [Windows Subsystem for
|
||||
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL), install a balena CLI release
|
||||
> for Linux rather than for Windows, like the standalone zip package for Linux. An installation
|
||||
> with the graphical executable installer for Windows will **not** work with WSL.
|
||||
|
||||
## Executable Installer
|
||||
|
||||
Recommended for Windows (but not Windows Subsystem for Linux) and macOS:
|
||||
|
||||
1. Download the latest installer from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
||||
Look for a file name that ends with "-installer", for example:
|
||||
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
|
||||
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
|
||||
|
||||
2. Double click the downloaded file to run the installer.
|
||||
_If you are using macOS Catalina (10.15), [check this known issue and
|
||||
workaround](https://github.com/balena-io/balena-cli/issues/1479)._
|
||||
|
||||
3. 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. Check that the installation was successful by running the following commands on a
|
||||
command terminal:
|
||||
|
||||
* `balena version` - should print the installed CLI version
|
||||
* `balena help` - should print the balena CLI help
|
||||
|
||||
> Note: If you had previously installed the CLI using a standalone zip package, it may be a good
|
||||
> idea to check your system's `PATH` environment variable for duplicate entries, as the terminal
|
||||
> will use the entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package)
|
||||
> instructions for how to modify the PATH variable.
|
||||
|
||||
By default, the CLI is installed to the following folders:
|
||||
|
||||
OS | Folders
|
||||
--- | ---
|
||||
Windows: | `C:\Program Files\balena-cli\`
|
||||
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
|
||||
|
||||
## Standalone Zip Package
|
||||
|
||||
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
|
||||
Look for a file name that ends with the word "standalone", for example:
|
||||
`balena-cli-vX.Y.Z-linux-x64-standalone.zip` ← _also for the Windows Subsystem for Linux_
|
||||
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
|
||||
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
|
||||
|
||||
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
|
||||
`balena-cli` folder.
|
||||
|
||||
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
|
||||
See instructions for:
|
||||
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
|
||||
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
|
||||
[Windows](https://www.computerhope.com/issues/ch000549.htm)
|
||||
|
||||
> * If you are using macOS Catalina (10.15), [check this known issue and
|
||||
> 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.
|
||||
> It should however work with all "desktop" or "server" distributions, e.g. Ubuntu, Debian, Suse,
|
||||
> Fedora, Arch Linux and many more.
|
||||
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
|
||||
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
|
||||
> folders and files also present in the `balena-cli` folder.
|
||||
|
||||
To update the 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.
|
||||
|
||||
## NPM Installation
|
||||
|
||||
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 additional development tools to be installed first:
|
||||
|
||||
* [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`
|
||||
|
||||
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
|
||||
|
||||
* 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-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 (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`
|
||||
|
||||
With these dependencies in place, the balena CLI installation command is:
|
||||
|
||||
```sh
|
||||
$ npm install balena-cli -g --production --unsafe-perm
|
||||
```
|
||||
|
||||
`--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` command requires a recent version of the `ssh` command-line tool to be available:
|
||||
* macOS and Linux usually already have it installed. Otherwise, search for the available packages
|
||||
on your specific Linux distribution, or for the Mac consider the [Xcode command-line
|
||||
tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/).
|
||||
|
||||
* Microsoft started distributing an SSH client with Windows 10, which we understand is
|
||||
automatically installed through Windows Update, but can be manually installed too
|
||||
([more information](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)).
|
||||
For other versions of Windows, there are several ssh/OpenSSH clients provided by 3rd parties.
|
||||
|
||||
* The [`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) is needed
|
||||
for the `balena ssh` command to work behind a proxy. It is available for Linux distributions
|
||||
like Ubuntu/Debian (`apt install proxytunnel`), and for macOS through
|
||||
[Homebrew](https://brew.sh/). Windows support is limited to the Windows Subsystem for Linux
|
||||
(e.g., by installing Ubuntu through the Microsoft App Store). Check the
|
||||
[README](https://github.com/balena-io/balena-cli/blob/master/README.md) file for proxy
|
||||
configuration instructions.
|
||||
|
||||
* The `balena preload`, `balena build` and `balena deploy --build` commands require
|
||||
[Docker](https://docs.docker.com/install/overview/) or [balenaEngine](https://www.balena.io/engine/)
|
||||
to be available:
|
||||
* The `balena preload` command requires the Docker Engine to support the [AUFS storage
|
||||
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Docker Desktop for Mac and
|
||||
Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1, so
|
||||
the workaround is to downgrade to version 18.06.1 (links: [Docker CE for
|
||||
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
|
||||
and [Docker CE for
|
||||
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)).
|
||||
See more details in [CLI issue 1099](https://github.com/balena-io/balena-cli/issues/1099).
|
||||
* Commonly, Docker is installed on the same machine where the CLI is being used, but the
|
||||
`balena build` and `balena deploy` commands can also use a remote Docker Engine (daemon)
|
||||
or balenaEngine (which could be a remote device running a [balenaOS development
|
||||
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)) by specifying
|
||||
its IP address and port number as command-line options. Check the documentation for each
|
||||
command, e.g. `balena help build`, or the [online
|
||||
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).
|
||||
* If you are using Microsoft's [Windows Subsystem for
|
||||
Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL) and Docker Desktop for
|
||||
Windows, check the [FAQ item "Docker seems to be
|
||||
unavailable"](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md#docker-seems-to-be-unavailable-error-when-using-windows-subsystem-for-linux-wsl).
|
||||
|
||||
* The `balena scan` command requires a multicast DNS (mDNS) service like Bonjour or Avahi:
|
||||
* On Windows, check if 'Bonjour' is installed (Control Panel > Programs and Features).
|
||||
If not, you can download Bonjour for Windows from https://support.apple.com/kb/DL999
|
||||
* Most 'desktop' Linux distributions ship with [Avahi](https://en.wikipedia.org/wiki/Avahi_(software)).
|
||||
Search for the installation command for your distribution. E.g. for Ubuntu:
|
||||
`sudo apt-get install avahi-daemon`
|
||||
* macOS comes with [Bonjour](https://en.wikipedia.org/wiki/Bonjour_(software)) built-in.
|
||||
|
||||
* The `balena os configure` command is currently not supported on Windows natively. Windows users are advised
|
||||
to install the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL)
|
||||
with Ubuntu, and use the Linux release of the balena CLI.
|
||||
|
||||
|
||||
## Configuring SSH keys
|
||||
|
||||
The `balena ssh` command requires an SSH key to be added to your balena account. If you had
|
||||
already added a SSH key in order to [deploy with 'git push'](https://www.balena.io/docs/learn/getting-started/raspberrypi3/nodejs/#adding-an-ssh-key),
|
||||
then you are probably done and may skip this section. You can check whether you already have
|
||||
an SSH key in your balena account with the `balena keys` command, or by visiting the
|
||||
[balena web dashboard](https://dashboard.balena-cloud.com/), clicking on your name -> Preferences
|
||||
-> SSH Keys.
|
||||
|
||||
> Note: An "SSH key" actually consists of a public/private key pair. A typical name for the private
|
||||
> key file is "id_rsa", and a typical name for the public key file is "id_rsa.pub". Both key files
|
||||
> are saved to your computer (with the private key optionally protected by a password), but only
|
||||
> the public key is saved to your balena account. This means that if you change computers or
|
||||
> otherwise lose the private key, _you cannot recover the private key through your balena account._
|
||||
> You can however add new keys, and delete the old ones.
|
||||
|
||||
If you don't have an SSH key in your balena account:
|
||||
|
||||
* If you have an existing SSH key in your computer that you would like to use, you can add it
|
||||
to your balena account through the balena web dashboard (Preferences -> SSH Keys), or through
|
||||
the CLI itself:
|
||||
|
||||
```bash
|
||||
# Windows 10 (cmd.exe prompt) example:
|
||||
$ balena key add MyKey %userprofile%\.ssh\id_rsa.pub
|
||||
# Linux / macOS example:
|
||||
$ balena key add MyKey ~/.ssh/id_rsa.pub
|
||||
```
|
||||
|
||||
* To generate a new key, you can follow [GitHub's documentation](https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent),
|
||||
skipping the step about adding the key to your GitHub account, and instead adding the key to
|
||||
your balena account as described above.
|
||||
> Note regarding WSL ([Windows Subsystem for
|
||||
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
|
||||
> If you would like to use WSL, follow the installations instructions for Linux
|
||||
> rather than Windows, as WSL consists of a Linux environment.
|
||||
|
82
README.md
82
README.md
@ -1,31 +1,30 @@
|
||||
# balena CLI
|
||||
|
||||
The official balena CLI tool.
|
||||
The official balena Command Line Interface.
|
||||
|
||||
[](http://badge.fury.io/js/balena-cli)
|
||||
[](https://david-dm.org/balena-io/balena-cli)
|
||||
|
||||
## About
|
||||
|
||||
The balena CLI (Command-Line Interface) allows you to interact with the balenaCloud and the
|
||||
[balena API](https://www.balena.io/docs/reference/api/overview/) through a terminal window
|
||||
on Linux, macOS or Windows. You can also write shell scripts around it, or import its Node.js
|
||||
modules to use it programmatically.
|
||||
As an [open-source project on GitHub](https://github.com/balena-io/balena-cli/), your contribution
|
||||
is also welcome!
|
||||
The balena CLI is a Command Line Interface for [balenaCloud](https://www.balena.io/cloud/) or
|
||||
[openBalena](https://www.balena.io/open/). It is a software tool available for Windows, macOS and
|
||||
Linux, used through a command prompt / terminal window. It can be used interactively or invoked in
|
||||
scripts. The balena CLI builds on the [balena API](https://www.balena.io/docs/reference/api/overview/)
|
||||
and the [balena SDK](https://www.balena.io/docs/reference/sdk/node-sdk/), and can also be directly
|
||||
imported in Node.js applications. The balena CLI is an [open-source project on
|
||||
GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also welcome!
|
||||
|
||||
## Installation
|
||||
|
||||
Check the [balena CLI installation instructions on GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
||||
Check the [balena CLI installation instructions on
|
||||
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Choosing a shell (command prompt/terminal)
|
||||
## Choosing a shell (command prompt/terminal)
|
||||
|
||||
On **Windows,** the standard Command Prompt (`cmd.exe`) and
|
||||
[PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell?view=powershell-6)
|
||||
are supported. We are aware of users also having a good experience with alternative shells,
|
||||
including:
|
||||
are supported. Alternative shells include:
|
||||
|
||||
* [MSYS2](https://www.msys2.org/):
|
||||
* Install additional packages with the command:
|
||||
@ -43,17 +42,17 @@ including:
|
||||
[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** is recommended. See
|
||||
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using balena
|
||||
CLI with WSL and Docker Desktop for Windows.
|
||||
balena CLI release **for Linux** should be selected. See
|
||||
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using the
|
||||
balena CLI with WSL and Docker Desktop for Windows.
|
||||
|
||||
On **macOS** and **Linux,** the standard terminal window is supported. _Optionally,_ `bash` command
|
||||
On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command
|
||||
auto completion may be enabled by copying the
|
||||
[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
|
||||
## Logging in
|
||||
|
||||
Several CLI commands require access to your balenaCloud account, for example in order to push a
|
||||
new release to your application. Those commands require creating a CLI login session by running:
|
||||
@ -62,7 +61,7 @@ new release to your application. Those commands require creating a CLI login ses
|
||||
$ balena login
|
||||
```
|
||||
|
||||
### Proxy support
|
||||
## Proxy support
|
||||
|
||||
HTTP(S) proxies can be configured through any of the following methods, in precedence order
|
||||
(from higher to lower):
|
||||
@ -88,19 +87,26 @@ HTTP(S) proxies can be configured through any of the following methods, in prece
|
||||
* The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as
|
||||
`BALENARC_PROXY`.
|
||||
|
||||
> Note: The `balena ssh` command has additional setup requirements to work behind a proxy.
|
||||
> Check the [installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md),
|
||||
> and ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
|
||||
> SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
|
||||
> server, it should be configured with the following rules in the `squid.conf` file:
|
||||
> `acl SSL_ports port 22`
|
||||
> `acl Safe_ports port 22`
|
||||
### Proxy setup for balena ssh
|
||||
|
||||
#### Proxy exclusion
|
||||
In order to work behind a proxy server, the `balena ssh` command requires the
|
||||
[`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed.
|
||||
`proxytunnel` is available for Linux distributions like Ubuntu/Debian (`apt install proxytunnel`),
|
||||
and for macOS through [Homebrew](https://brew.sh/). Windows support is limited to the [Windows
|
||||
Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (e.g., by installing
|
||||
Ubuntu through the Microsoft App Store).
|
||||
|
||||
Ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
|
||||
SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
|
||||
server, it should be configured with the following rules in the `squid.conf` file:
|
||||
`acl SSL_ports port 22`
|
||||
`acl Safe_ports port 22`
|
||||
|
||||
### Proxy exclusion
|
||||
|
||||
The `BALENARC_NO_PROXY` variable may be used to exclude specified destinations from proxying.
|
||||
|
||||
> * This feature requires balena CLI version 11.30.8 or later. In the case of the npm [installation
|
||||
> * This feature requires CLI version 11.30.8 or later. In the case of the npm [installation
|
||||
> option](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md), it also requires
|
||||
> Node.js version 10.16.0 or later.
|
||||
> * To exclude a `balena ssh` target from proxying (IP address or `.local` hostname), the
|
||||
@ -129,25 +135,27 @@ address like `192.168.1.2`.
|
||||
## Command reference documentation
|
||||
|
||||
The full CLI command reference is available [on the web](https://www.balena.io/docs/reference/cli/
|
||||
) or by running `balena help` and `balena help --verbose`.
|
||||
) or by running `balena help --verbose`.
|
||||
|
||||
## Support, FAQ and troubleshooting
|
||||
|
||||
If you come across any problems or would like to get in touch:
|
||||
To learn more, troubleshoot issues, or to contact us for support:
|
||||
|
||||
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md).
|
||||
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud).
|
||||
* For bug reports or feature requests,
|
||||
[have a look at the GitHub issues or create a new one](https://github.com/balena-io/balena-cli/issues/).
|
||||
* 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 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/).
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
The balena CLI uses [semver versioning](https://semver.org/), with the concepts
|
||||
of major, minor and patch version releases.
|
||||
|
||||
The latest release of the previous major version of the balena CLI will remain
|
||||
compatible with the balenaCloud backend services for one year from the date when
|
||||
the next major version is released. For example, balena CLI v10.17.5, as the
|
||||
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 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.
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
# FAQ & Troubleshooting
|
||||
# balena CLI FAQ & Troubleshooting
|
||||
|
||||
This document contains some common issues, questions and answers related to the balena CLI.
|
||||
|
||||
## Where is my configuration file?
|
||||
## Where is the balena CLI's configuration file located?
|
||||
|
||||
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
|
||||
Unix based operating systems and Windows respectively.
|
||||
@ -10,53 +8,43 @@ Unix based operating systems and Windows respectively.
|
||||
The balena CLI also attempts to read a `balenarc.yml` file in the current directory, which takes
|
||||
precedence over the per-user configuration file.
|
||||
|
||||
## How do I point the balena CLI to staging?
|
||||
## How do I point the balena CLI to the staging environment?
|
||||
|
||||
The easiest way is to set the `BALENARC_BALENA_URL=balena-staging.com` environment variable.
|
||||
|
||||
Alternatively, you can edit your configuration file and set `balenaUrl: balena-staging.com` to
|
||||
persist this setting.
|
||||
Set the `BALENARC_BALENA_URL=balena-staging.com` environment variable, or add
|
||||
`balenaUrl: balena-staging.com` to the balena CLI's configuration file.
|
||||
|
||||
## How do I make the balena CLI persist data in another directory?
|
||||
|
||||
The balena CLI persists your session token, as well as cached images in `$HOME/.balena` or
|
||||
`%UserProfile%\_balena`.
|
||||
The balena CLI persists the session token, as well as cached assets, to `$HOME/.balena` or
|
||||
`%UserProfile%\_balena`. This directory can be changed by setting an environment variable,
|
||||
`BALENARC_DATA_DIRECTORY=/opt/balena`, or by adding `dataDirectory: /opt/balena` to the CLI's
|
||||
configuration file, replacing `/opt/balena` with the desired directory.
|
||||
|
||||
Pointing the balena CLI to persist data in another location is necessary in certain environments,
|
||||
like a server, where there is no home directory, or a device running balenaOS, which erases all
|
||||
data after a restart.
|
||||
## After burning to an SD card, my device doesn't boot
|
||||
|
||||
You can accomplish this by setting `BALENARC_DATA_DIRECTORY=/opt/balena` or adding `dataDirectory:
|
||||
/opt/balena` to your configuration file, replacing `/opt/balena` with your desired directory.
|
||||
Check whether the downloaded image is incomplete (download was interrupted) or corrupted.
|
||||
|
||||
## After burning to an sdcard, my device doesn't boot
|
||||
Try clearing the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and running the
|
||||
command again.
|
||||
|
||||
- The downloaded image is not complete (download was interrupted).
|
||||
## I get a permission error when burning to an SD card
|
||||
|
||||
Please clean the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and run the command again. In the future, the CLI will check that the image is not complete and clean the cache for you.
|
||||
Check whether the SD card is locked (a physical switch on the side of the card).
|
||||
|
||||
## I get a permission error when burning to an sdcard
|
||||
## I get EINVAL errors on Cygwin
|
||||
|
||||
- The SDCard is locked.
|
||||
|
||||
### I get EINVAL errors on Cygwin
|
||||
|
||||
The errors look something like this:
|
||||
The errors may look something like this:
|
||||
|
||||
```
|
||||
net.js:156
|
||||
this._handle.open(options.fd);
|
||||
^
|
||||
Error: EINVAL, invalid argument
|
||||
at new Socket (net.js:156:18)
|
||||
at process.stdin (node.js:664:19)
|
||||
at Object.Interface.createInterface (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\node_modules\readline2\index.js:31:43)
|
||||
at PromptUI.UI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\baseUI.js:23:40)
|
||||
at new PromptUI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\prompt.js:26:8)
|
||||
at Object.promptModule [as prompt] (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\inquirer.js:27:14)
|
||||
```
|
||||
|
||||
- Some interactive widgets don't work on `Cygwin`. If you're running Windows, it's preferrable that you use `cmd.exe`, as `Cygwin` is [not official supported by Node.js](https://github.com/chjj/blessed/issues/56#issuecomment-42671945).
|
||||
Some interactive widgets don't work on `Cygwin`. On Windows, PowerShell or `cmd.exe` are better
|
||||
supported. Alternative shells are [listed in the README
|
||||
file](./README.md#choosing-a-shell-command-promptterminal).
|
||||
|
||||
## I get `Invalid MBR boot signature` when configuring a device
|
||||
|
||||
@ -76,7 +64,9 @@ Or in Windows:
|
||||
|
||||
## I get `EACCES: permission denied` when logging in
|
||||
|
||||
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based operating systems and Windows respectively. This error usually indicates that the user doesn't have permissions over that directory, which can happen if you ran the balena CLI as `root`, and thus the directory got owned by him.
|
||||
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based
|
||||
operating systems and Windows respectively. This error usually indicates that the user doesn't have
|
||||
permissions over that directory, which can happen if the CLI was executed as the `root` user.
|
||||
|
||||
Try resetting the ownership by running:
|
||||
|
||||
@ -86,7 +76,15 @@ $ sudo chown -R <user> $HOME/.balena
|
||||
|
||||
## Broken line wrapping / cursor behavior with `balena ssh`
|
||||
|
||||
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example when long command lines are typed in a `balena ssh` session, or when using text editors like `vim` or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile` and the like), including UTF-8 misconfiguration, the use of unsupported ASCII control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or log files that use colored text. The issue can sometimes be fixed by resizing the client terminal window, or by running one or more of the following commands on the shell:
|
||||
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example
|
||||
when long command lines are typed in a `balena ssh` session, or when using text editors like `vim`
|
||||
or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue
|
||||
with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell
|
||||
configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`
|
||||
and the like on the remote machine), including UTF-8 misconfiguration, the use of unsupported ASCII
|
||||
control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or
|
||||
log files that use colored text. The issue can sometimes be fixed by simply resizing the client
|
||||
terminal window, or by running one or more of the following commands on the shell:
|
||||
|
||||
```sh
|
||||
export TERMINAL=linux
|
||||
@ -112,10 +110,10 @@ If nothing seems to help, consider also using a different client-side terminal a
|
||||
## "Docker seems to be unavailable" error when using Windows Subsystem for Linux (WSL)
|
||||
|
||||
When running on WSL, the recommendation is to install a CLI release for Linux, like the standalone
|
||||
zip package for Linux. However, commands like "balena build" that contact a local Docker daemon,
|
||||
like the Docker Desktop for Windows, will try to reach Docker at the Unix socket path
|
||||
`/var/run/docker.sock`, while Docker Desktop for Windows uses a Windows named pipe at
|
||||
`//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A solution is:
|
||||
zip package for Linux. However, commands like "balena build" will, by default, attempt to reach the
|
||||
Docker daemon at the Unix socket path `/var/run/docker.sock`, while Docker Desktop for Windows uses
|
||||
a Windows named pipe at `//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A
|
||||
solution is:
|
||||
|
||||
- Open the Docker Desktop for Windows settings panel and tick the checkbox _"Expose daemon on tcp://localhost:2375 without TLS"._
|
||||
- On the WSL command line, set an env var:
|
||||
|
@ -15,7 +15,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { JsonVersions } from '../lib/actions-oclif/version';
|
||||
import type { JsonVersions } from '../lib/commands/version';
|
||||
|
||||
import { run as oclifRun } from '@oclif/dev-cli';
|
||||
import * as archiver from 'archiver';
|
||||
@ -25,17 +25,18 @@ import * as filehound from 'filehound';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
import { exec as execPkg } from 'pkg';
|
||||
import * as rimraf from 'rimraf';
|
||||
import * as semver from 'semver';
|
||||
import * as util from 'util';
|
||||
|
||||
import { stripIndent } from '../lib/utils/lazy';
|
||||
import {
|
||||
diffLines,
|
||||
getSubprocessStdout,
|
||||
loadPackageJson,
|
||||
MSYS2_BASH,
|
||||
ROOT,
|
||||
StdOutTap,
|
||||
whichSpawn,
|
||||
} from './utils';
|
||||
|
||||
@ -73,6 +74,99 @@ export const finalReleaseAssets: { [platform: string]: string[] } = {
|
||||
linux: [standaloneZips['linux']],
|
||||
};
|
||||
|
||||
/**
|
||||
* Given the output of `pkg` as a string (containing warning messages),
|
||||
* diff it against previously saved output of known "safe" warnings.
|
||||
* Throw an error if the diff is not empty.
|
||||
*/
|
||||
async function diffPkgOutput(pkgOut: string) {
|
||||
const { monochrome } = await import('../tests/helpers');
|
||||
const relSavedPath = path.join(
|
||||
'tests',
|
||||
'test-data',
|
||||
'pkg',
|
||||
`expected-warnings-${process.platform}.txt`,
|
||||
);
|
||||
const absSavedPath = path.join(ROOT, relSavedPath);
|
||||
const ignoreStartsWith = [
|
||||
'> pkg@',
|
||||
'> Fetching base Node.js binaries',
|
||||
' fetched-',
|
||||
];
|
||||
const modulesRE =
|
||||
process.platform === 'win32'
|
||||
? /(?<=[ '])([A-Z]:)?\\.+?\\node_modules(?=\\)/
|
||||
: /(?<=[ '])\/.+?\/node_modules(?=\/)/;
|
||||
const buildRE =
|
||||
process.platform === 'win32'
|
||||
? /(?<=[ '])([A-Z]:)?\\.+\\build(?=\\)/
|
||||
: /(?<=[ '])\/.+\/build(?=\/)/;
|
||||
|
||||
const cleanLines = (chunks: string | string[]) => {
|
||||
const lines = typeof chunks === 'string' ? chunks.split('\n') : chunks;
|
||||
return lines
|
||||
.map((line: string) => monochrome(line)) // remove ASCII colors
|
||||
.filter((line: string) => !/^\s*$/.test(line)) // blank lines
|
||||
.filter((line: string) =>
|
||||
ignoreStartsWith.every((i) => !line.startsWith(i)),
|
||||
)
|
||||
.map((line: string) => {
|
||||
// replace absolute paths with relative paths
|
||||
let replaced = line.replace(modulesRE, 'node_modules');
|
||||
if (replaced === line) {
|
||||
replaced = line.replace(buildRE, 'build');
|
||||
}
|
||||
return replaced;
|
||||
});
|
||||
};
|
||||
|
||||
pkgOut = cleanLines(pkgOut).join('\n');
|
||||
const { readFile } = (await import('fs')).promises;
|
||||
const expectedOut = cleanLines(await readFile(absSavedPath, 'utf8')).join(
|
||||
'\n',
|
||||
);
|
||||
if (expectedOut !== pkgOut) {
|
||||
const sep =
|
||||
'================================================================================';
|
||||
const diff = diffLines(expectedOut, pkgOut);
|
||||
const msg = `pkg output does not match expected output from "${relSavedPath}"
|
||||
Diff:
|
||||
${sep}
|
||||
${diff}
|
||||
${sep}
|
||||
Check whether the new or changed pkg warnings are safe to ignore, then update
|
||||
"${relSavedPath}"
|
||||
and share the result of your investigation as comments on the pull request.
|
||||
Hint: the fix is often a matter of updating the 'pkg.scripts' or 'pkg.assets'
|
||||
sections in the CLI's 'package.json' file, or a matter of updating the
|
||||
'buildPkg' function in 'automation/build-bin.ts'. Sometimes it requires
|
||||
patching dependencies: See for example 'patches/all/open+7.0.2.patch'.
|
||||
${sep}
|
||||
`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `pkg.exec` to generate the standalone zip file, capturing its warning
|
||||
* messages (stdout and stderr) in order to call diffPkgOutput().
|
||||
*/
|
||||
async function execPkg(...args: any[]) {
|
||||
const { exec: pkgExec } = await import('pkg');
|
||||
const outTap = new StdOutTap(true);
|
||||
try {
|
||||
outTap.tap();
|
||||
await (pkgExec as any)(...args);
|
||||
} catch (err) {
|
||||
outTap.untap();
|
||||
console.log(outTap.stdoutBuf.join(''));
|
||||
console.error(outTap.stderrBuf.join(''));
|
||||
throw err;
|
||||
}
|
||||
outTap.untap();
|
||||
await diffPkgOutput(outTap.allBuf.join(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the 'pkg' module to create a single large executable file with
|
||||
* the contents of 'node_modules' and the CLI's javascript code.
|
||||
@ -208,11 +302,11 @@ export async function buildStandaloneZip() {
|
||||
await buildPkg();
|
||||
await testPkg();
|
||||
await zipPkg();
|
||||
console.log(`Standalone zip package build completed`);
|
||||
} catch (error) {
|
||||
console.log(`Error creating or testing standalone zip package:\n ${error}`);
|
||||
process.exit(1);
|
||||
console.error(`Error creating or testing standalone zip package`);
|
||||
throw error;
|
||||
}
|
||||
console.log(`Standalone zip package build completed`);
|
||||
}
|
||||
|
||||
async function renameInstallerFiles() {
|
||||
@ -315,3 +409,12 @@ export async function catchUncommitted(): Promise<void> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testShrinkwrap(): Promise<void> {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(`[debug] platform=${process.platform}`);
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await whichSpawn(path.resolve(__dirname, 'test-lock-deduplicated.sh'));
|
||||
}
|
||||
}
|
||||
|
@ -26,145 +26,153 @@ import { MarkdownFileParser } from './utils';
|
||||
* some content to this object.
|
||||
*/
|
||||
const capitanoDoc = {
|
||||
title: 'Balena CLI Documentation',
|
||||
title: 'balena CLI Documentation',
|
||||
introduction: '',
|
||||
categories: [
|
||||
{
|
||||
title: 'API keys',
|
||||
files: ['build/actions-oclif/api-key/generate.js'],
|
||||
files: ['build/commands/api-key/generate.js'],
|
||||
},
|
||||
{
|
||||
title: 'Application',
|
||||
files: [
|
||||
'build/actions-oclif/apps.js',
|
||||
'build/actions-oclif/app/index.js',
|
||||
'build/actions-oclif/app/create.js',
|
||||
'build/actions-oclif/app/rm.js',
|
||||
'build/actions-oclif/app/restart.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',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
files: [
|
||||
'build/actions-oclif/login.js',
|
||||
'build/actions-oclif/logout.js',
|
||||
'build/actions-oclif/whoami.js',
|
||||
'build/commands/login.js',
|
||||
'build/commands/logout.js',
|
||||
'build/commands/whoami.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Device',
|
||||
files: [
|
||||
'build/actions-oclif/device/identify.js',
|
||||
'build/actions-oclif/device/init.js',
|
||||
'build/actions-oclif/device/index.js',
|
||||
'build/actions-oclif/device/move.js',
|
||||
'build/actions-oclif/device/reboot.js',
|
||||
'build/actions-oclif/device/register.js',
|
||||
'build/actions-oclif/device/rename.js',
|
||||
'build/actions-oclif/device/rm.js',
|
||||
'build/actions-oclif/device/shutdown.js',
|
||||
'build/actions-oclif/devices/index.js',
|
||||
'build/actions-oclif/devices/supported.js',
|
||||
'build/actions-oclif/device/os-update.js',
|
||||
'build/actions-oclif/device/public-url.js',
|
||||
'build/commands/devices/index.js',
|
||||
'build/commands/devices/supported.js',
|
||||
'build/commands/device/index.js',
|
||||
'build/commands/device/identify.js',
|
||||
'build/commands/device/init.js',
|
||||
'build/commands/device/move.js',
|
||||
'build/commands/device/os-update.js',
|
||||
'build/commands/device/public-url.js',
|
||||
'build/commands/device/purge.js',
|
||||
'build/commands/device/reboot.js',
|
||||
'build/commands/device/register.js',
|
||||
'build/commands/device/rename.js',
|
||||
'build/commands/device/restart.js',
|
||||
'build/commands/device/rm.js',
|
||||
'build/commands/device/shutdown.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Environment Variables',
|
||||
files: [
|
||||
'build/actions-oclif/envs.js',
|
||||
'build/actions-oclif/env/add.js',
|
||||
'build/actions-oclif/env/rename.js',
|
||||
'build/actions-oclif/env/rm.js',
|
||||
'build/commands/envs.js',
|
||||
'build/commands/env/add.js',
|
||||
'build/commands/env/rename.js',
|
||||
'build/commands/env/rm.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
files: [
|
||||
'build/actions-oclif/tags.js',
|
||||
'build/actions-oclif/tag/rm.js',
|
||||
'build/actions-oclif/tag/set.js',
|
||||
'build/commands/tags.js',
|
||||
'build/commands/tag/rm.js',
|
||||
'build/commands/tag/set.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Help and Version',
|
||||
files: ['build/actions/help.js', 'build/actions-oclif/version.js'],
|
||||
files: ['help', 'build/commands/version.js'],
|
||||
},
|
||||
{
|
||||
title: 'Keys',
|
||||
files: [
|
||||
'build/actions-oclif/keys.js',
|
||||
'build/actions-oclif/key/index.js',
|
||||
'build/actions-oclif/key/add.js',
|
||||
'build/actions-oclif/key/rm.js',
|
||||
'build/commands/keys.js',
|
||||
'build/commands/key/index.js',
|
||||
'build/commands/key/add.js',
|
||||
'build/commands/key/rm.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
files: ['build/actions-oclif/logs.js'],
|
||||
files: ['build/commands/logs.js'],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
files: [
|
||||
'build/actions-oclif/scan.js',
|
||||
'build/actions-oclif/ssh.js',
|
||||
'build/actions-oclif/tunnel.js',
|
||||
'build/commands/scan.js',
|
||||
'build/commands/ssh.js',
|
||||
'build/commands/tunnel.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notes',
|
||||
files: ['build/actions-oclif/note.js'],
|
||||
files: ['build/commands/note.js'],
|
||||
},
|
||||
{
|
||||
title: 'OS',
|
||||
files: [
|
||||
'build/actions-oclif/os/build-config.js',
|
||||
'build/actions-oclif/os/configure.js',
|
||||
'build/actions-oclif/os/versions.js',
|
||||
'build/actions-oclif/os/download.js',
|
||||
'build/actions-oclif/os/initialize.js',
|
||||
'build/commands/os/build-config.js',
|
||||
'build/commands/os/configure.js',
|
||||
'build/commands/os/versions.js',
|
||||
'build/commands/os/download.js',
|
||||
'build/commands/os/initialize.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Config',
|
||||
files: [
|
||||
'build/actions-oclif/config/generate.js',
|
||||
'build/actions-oclif/config/inject.js',
|
||||
'build/actions-oclif/config/read.js',
|
||||
'build/actions-oclif/config/reconfigure.js',
|
||||
'build/actions-oclif/config/write.js',
|
||||
'build/commands/config/generate.js',
|
||||
'build/commands/config/inject.js',
|
||||
'build/commands/config/read.js',
|
||||
'build/commands/config/reconfigure.js',
|
||||
'build/commands/config/write.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preload',
|
||||
files: ['build/actions/preload.js'],
|
||||
files: ['build/commands/preload.js'],
|
||||
},
|
||||
{
|
||||
title: 'Push',
|
||||
files: ['build/actions-oclif/push.js'],
|
||||
files: ['build/commands/push.js'],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
files: ['build/actions-oclif/settings.js'],
|
||||
files: ['build/commands/settings.js'],
|
||||
},
|
||||
{
|
||||
title: 'Local',
|
||||
files: [
|
||||
'build/actions-oclif/local/configure.js',
|
||||
'build/actions-oclif/local/flash.js',
|
||||
'build/commands/local/configure.js',
|
||||
'build/commands/local/flash.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Deploy',
|
||||
files: ['build/actions/build.js', 'build/actions/deploy.js'],
|
||||
files: ['build/commands/build.js', 'build/commands/deploy.js'],
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
files: ['build/actions-oclif/join.js', 'build/actions-oclif/leave.js'],
|
||||
files: ['build/commands/join.js', 'build/commands/leave.js'],
|
||||
},
|
||||
{
|
||||
title: 'Utilities',
|
||||
files: ['build/actions-oclif/util/available-drives.js'],
|
||||
files: ['build/commands/util/available-drives.js'],
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
files: ['build/commands/support.js'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -191,7 +199,9 @@ export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
|
||||
return match && match[2];
|
||||
}),
|
||||
mdParser.getSectionOfTitle('Installation'),
|
||||
mdParser.getSectionOfTitle('Getting Started'),
|
||||
mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'),
|
||||
mdParser.getSectionOfTitle('Logging in'),
|
||||
mdParser.getSectionOfTitle('Proxy support'),
|
||||
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
|
||||
mdParser.getSectionOfTitle('Deprecation policy'),
|
||||
]);
|
||||
|
5
automation/capitanodoc/doc-types.d.ts
vendored
5
automation/capitanodoc/doc-types.d.ts
vendored
@ -15,7 +15,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Command as OclifCommandClass } from '@oclif/command';
|
||||
import { CommandDefinition as CapitanoCommand } from 'capitano';
|
||||
|
||||
type OclifCommand = typeof OclifCommandClass;
|
||||
|
||||
@ -27,7 +26,7 @@ export interface Document {
|
||||
|
||||
export interface Category {
|
||||
title: string;
|
||||
commands: Array<CapitanoCommand | OclifCommand>;
|
||||
commands: OclifCommand[];
|
||||
}
|
||||
|
||||
export { CapitanoCommand, OclifCommand };
|
||||
export { OclifCommand };
|
||||
|
@ -14,12 +14,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getCapitanoDoc } from './capitanodoc';
|
||||
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
|
||||
import { Category, Document, OclifCommand } from './doc-types';
|
||||
import * as markdown from './markdown';
|
||||
import { stripIndent } from '../../lib/utils/lazy';
|
||||
|
||||
/**
|
||||
* Generates the markdown document (as a string) for the CLI documentation
|
||||
@ -40,11 +39,7 @@ export async function renderMarkdown(): Promise<string> {
|
||||
};
|
||||
|
||||
for (const jsFilename of commandCategory.files) {
|
||||
category.commands.push(
|
||||
...(jsFilename.includes('actions-oclif')
|
||||
? importOclifCommands(jsFilename)
|
||||
: importCapitanoCommands(jsFilename)),
|
||||
);
|
||||
category.commands.push(...importOclifCommands(jsFilename));
|
||||
}
|
||||
result.categories.push(category);
|
||||
}
|
||||
@ -52,27 +47,48 @@ export async function renderMarkdown(): Promise<string> {
|
||||
return markdown.render(result);
|
||||
}
|
||||
|
||||
function importCapitanoCommands(jsFilename: string): CapitanoCommand[] {
|
||||
const actions = require(path.join(process.cwd(), jsFilename));
|
||||
const commands: CapitanoCommand[] = [];
|
||||
// Help is now managed via a plugin
|
||||
// This fake command allows capitanodoc to include help in docs
|
||||
class FakeHelpCommand {
|
||||
description = stripIndent`
|
||||
List balena commands, or get detailed help for a specific command.
|
||||
|
||||
if (actions.signature) {
|
||||
commands.push(_.omit(actions, 'action') as any);
|
||||
} else {
|
||||
for (const actionName of Object.keys(actions)) {
|
||||
const actionCommand = actions[actionName];
|
||||
commands.push(_.omit(actionCommand, 'action') as any);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
List balena commands, or get detailed help for a specific command.
|
||||
`;
|
||||
|
||||
examples = [
|
||||
'$ balena help',
|
||||
'$ balena help apps',
|
||||
'$ balena help os download',
|
||||
];
|
||||
|
||||
args = [
|
||||
{
|
||||
name: 'command',
|
||||
description: 'command to show help for',
|
||||
},
|
||||
];
|
||||
|
||||
usage = 'help [command]';
|
||||
|
||||
flags = {
|
||||
verbose: {
|
||||
description: 'show additional commands',
|
||||
char: '-v',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function importOclifCommands(jsFilename: string): OclifCommand[] {
|
||||
// TODO: Currently oclif commands with no `usage` overridden will cause
|
||||
// an error when parsed. This should be improved so that `usage` does not have
|
||||
// to be overridden if not necessary.
|
||||
const command: OclifCommand = require(path.join(process.cwd(), jsFilename))
|
||||
.default as OclifCommand;
|
||||
|
||||
const command: OclifCommand =
|
||||
jsFilename === 'help'
|
||||
? ((new FakeHelpCommand() as unknown) as OclifCommand)
|
||||
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
|
||||
|
||||
return [command];
|
||||
}
|
||||
|
||||
|
@ -20,33 +20,10 @@ import * as _ from 'lodash';
|
||||
|
||||
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
|
||||
import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
|
||||
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
|
||||
import * as utils from './utils';
|
||||
|
||||
function renderCapitanoCommand(command: CapitanoCommand): string[] {
|
||||
const result = [`## ${ent.encode(command.signature)}`, command.help!];
|
||||
|
||||
if (!_.isEmpty(command.options)) {
|
||||
result.push('### Options');
|
||||
|
||||
for (const option of command.options!) {
|
||||
if (option == null) {
|
||||
throw new Error(`Undefined option in markdown generation!`);
|
||||
}
|
||||
if (option.description == null) {
|
||||
throw new Error(`Undefined option.description in markdown generation!`);
|
||||
}
|
||||
result.push(
|
||||
`#### ${utils.parseCapitanoOption(option)}`,
|
||||
option.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import { Category, Document, OclifCommand } from './doc-types';
|
||||
|
||||
function renderOclifCommand(command: OclifCommand): string[] {
|
||||
const result = [`## ${ent.encode(command.usage)}`];
|
||||
const result = [`## ${ent.encode(command.usage || '')}`];
|
||||
const description = (command.description || '')
|
||||
.split('\n')
|
||||
.slice(1) // remove the first line, which oclif uses as help header
|
||||
@ -86,11 +63,7 @@ function renderOclifCommand(command: OclifCommand): string[] {
|
||||
function renderCategory(category: Category): string[] {
|
||||
const result = [`# ${category.title}`];
|
||||
for (const command of category.commands) {
|
||||
result.push(
|
||||
...(typeof command === 'object'
|
||||
? renderCapitanoCommand(command)
|
||||
: renderOclifCommand(command)),
|
||||
);
|
||||
result.push(...renderOclifCommand(command));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -107,10 +80,7 @@ function renderToc(categories: Category[]): string[] {
|
||||
result.push(
|
||||
category.commands
|
||||
.map((command) => {
|
||||
const signature =
|
||||
typeof command === 'object'
|
||||
? command.signature // Capitano
|
||||
: capitanoizeOclifUsage(command.usage); // oclif
|
||||
const signature = capitanoizeOclifUsage(command.usage);
|
||||
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
|
||||
})
|
||||
.join('\n'),
|
||||
@ -134,12 +104,10 @@ function sortCommands(doc: Document): void {
|
||||
for (const category of doc.categories) {
|
||||
if (category.title in manualCategorySorting) {
|
||||
category.commands = category.commands.sort(
|
||||
getManualSortCompareFunction<CapitanoCommand | OclifCommand, string>(
|
||||
getManualSortCompareFunction<OclifCommand, string>(
|
||||
manualCategorySorting[category.title],
|
||||
(cmd: CapitanoCommand | OclifCommand, x: string) =>
|
||||
typeof cmd === 'object' // Capitano vs oclif command
|
||||
? cmd.signature.replace(/\W+/g, ' ').includes(x)
|
||||
: (cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
|
||||
(cmd: OclifCommand, x: string) =>
|
||||
(cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
buildOclifInstaller,
|
||||
buildStandaloneZip,
|
||||
catchUncommitted,
|
||||
testShrinkwrap,
|
||||
} from './build-bin';
|
||||
import {
|
||||
release,
|
||||
@ -63,6 +64,7 @@ export async function run(args?: string[]) {
|
||||
'build:installer': buildOclifInstaller,
|
||||
'build:standalone': buildStandaloneZip,
|
||||
'catch-uncommitted': catchUncommitted,
|
||||
'test-shrinkwrap': testShrinkwrap,
|
||||
fix1359: updateDescriptionOfReleasesAffectedByIssue1359,
|
||||
release,
|
||||
};
|
||||
|
17
automation/test-lock-deduplicated.sh
Executable file
17
automation/test-lock-deduplicated.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cp npm-shrinkwrap.json npm-shrinkwrap.json.old
|
||||
npm i
|
||||
npm dedupe
|
||||
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 "** Please run 'npm ci', followed by 'npm dedupe' **";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
rm npm-shrinkwrap.json.old
|
@ -23,6 +23,69 @@ import * as shellEscape from 'shell-escape';
|
||||
export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
|
||||
export const ROOT = path.join(__dirname, '..');
|
||||
|
||||
/** Tap and buffer this process' stdout and stderr */
|
||||
export class StdOutTap {
|
||||
public stdoutBuf: string[] = [];
|
||||
public stderrBuf: string[] = [];
|
||||
public allBuf: string[] = []; // both stdout and stderr
|
||||
|
||||
protected origStdoutWrite: typeof process.stdout.write;
|
||||
protected origStderrWrite: typeof process.stdout.write;
|
||||
|
||||
constructor(protected printDots = false) {}
|
||||
|
||||
tap() {
|
||||
this.origStdoutWrite = process.stdout.write;
|
||||
this.origStderrWrite = process.stderr.write;
|
||||
|
||||
process.stdout.write = (chunk: string, ...args: any[]): boolean => {
|
||||
this.stdoutBuf.push(chunk);
|
||||
this.allBuf.push(chunk);
|
||||
const str = this.printDots ? '.' : chunk;
|
||||
return this.origStdoutWrite.call(process.stdout, str, ...args);
|
||||
};
|
||||
|
||||
process.stderr.write = (chunk: string, ...args: any[]): boolean => {
|
||||
this.stderrBuf.push(chunk);
|
||||
this.allBuf.push(chunk);
|
||||
const str = this.printDots ? '.' : chunk;
|
||||
return this.origStderrWrite.call(process.stderr, str, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
untap() {
|
||||
process.stdout.write = this.origStdoutWrite;
|
||||
process.stderr.write = this.origStderrWrite;
|
||||
if (this.printDots) {
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff strings by line, using the 'diff' npm package:
|
||||
* https://www.npmjs.com/package/diff
|
||||
*/
|
||||
export function diffLines(str1: string, str2: string): string {
|
||||
const { diffTrimmedLines } = require('diff');
|
||||
const diffObjs = diffTrimmedLines(str1, str2);
|
||||
const prefix = (chunk: string, char: string) =>
|
||||
chunk
|
||||
.split('\n')
|
||||
.map((line: string) => `${char} ${line}`)
|
||||
.join('\n');
|
||||
const diffStr = diffObjs
|
||||
.map((part: any) => {
|
||||
return part.added
|
||||
? prefix(part.value, '+')
|
||||
: part.removed
|
||||
? prefix(part.value, '-')
|
||||
: prefix(part.value, ' ');
|
||||
})
|
||||
.join('\n');
|
||||
return diffStr;
|
||||
}
|
||||
|
||||
export function loadPackageJson() {
|
||||
return require(path.join(ROOT, 'package.json'));
|
||||
}
|
||||
@ -143,7 +206,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;
|
||||
|
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// tslint:disable:no-var-requires
|
||||
|
||||
// We boost the threadpool size as ext2fs can deadlock with some
|
||||
// operations otherwise, if the pool runs out.
|
||||
process.env.UV_THREADPOOL_SIZE = '64';
|
||||
@ -10,7 +12,7 @@ process.env.OCLIF_TS_NODE = 0;
|
||||
// Use fast-boot to cache require lookups, speeding up startup
|
||||
require('fast-boot2').start({
|
||||
cacheScope: __dirname + '/..',
|
||||
cacheFile: __dirname + '/.fast-boot.json'
|
||||
cacheFile: __dirname + '/.fast-boot.json',
|
||||
});
|
||||
|
||||
// Set the desired es version for downstream modules that support it
|
||||
|
@ -1,14 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// ****************************************************************************
|
||||
// THIS IS FOR DEV PERROSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
|
||||
// THIS IS FOR DEV PURPOSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
|
||||
// Before opening a PR you should build and test your changes using bin/balena
|
||||
// ****************************************************************************
|
||||
|
||||
// tslint:disable:no-var-requires
|
||||
|
||||
// We boost the threadpool size as ext2fs can deadlock with some
|
||||
// operations otherwise, if the pool runs out.
|
||||
process.env.UV_THREADPOOL_SIZE = '64';
|
||||
|
||||
const path = require('path');
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
|
||||
// Allow balena-dev to work with oclif by temporarily
|
||||
// pointing oclif config options to lib/ instead of build/
|
||||
modifyOclifPaths();
|
||||
// Undo changes on exit
|
||||
process.on('exit', function () {
|
||||
modifyOclifPaths(true);
|
||||
});
|
||||
// Undo changes in case of ctrl-v
|
||||
process.on('SIGINT', function () {
|
||||
modifyOclifPaths(true);
|
||||
});
|
||||
|
||||
// Use fast-boot to cache require lookups, speeding up startup
|
||||
require('fast-boot2').start({
|
||||
cacheScope: __dirname + '/..',
|
||||
@ -18,8 +35,6 @@ require('fast-boot2').start({
|
||||
// Set the desired es version for downstream modules that support it
|
||||
require('@balena/es-version').set('es2018');
|
||||
|
||||
const path = require('path');
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
|
||||
// default option. We upgraded ts-node and found that adding 'transpile-only'
|
||||
// was necessary to avoid a mysterious 'null' error message. On the plus side,
|
||||
@ -30,3 +45,30 @@ require('ts-node').register({
|
||||
transpileOnly: true,
|
||||
});
|
||||
require('../lib/app').run();
|
||||
|
||||
// Modify package.json oclif paths from build/ -> lib/, or vice versa
|
||||
function modifyOclifPaths(revert) {
|
||||
const fs = require('fs');
|
||||
const packageJsonPath = path.join(rootDir, 'package.json');
|
||||
|
||||
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageObj = JSON.parse(packageJson);
|
||||
|
||||
if (!packageObj.oclif) {
|
||||
return;
|
||||
}
|
||||
|
||||
let oclifSectionText = JSON.stringify(packageObj.oclif);
|
||||
if (!revert) {
|
||||
oclifSectionText = oclifSectionText.replace(/\/build\//g, '/lib/');
|
||||
} else {
|
||||
oclifSectionText = oclifSectionText.replace(/\/lib\//g, '/build/');
|
||||
}
|
||||
|
||||
packageObj.oclif = JSON.parse(oclifSectionText);
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
`${JSON.stringify(packageObj, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
1131
doc/cli.markdown
1131
doc/cli.markdown
File diff suppressed because it is too large
Load Diff
@ -1,206 +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 * as dockerUtils from '../utils/docker';
|
||||
import * as compose from '../utils/compose';
|
||||
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
|
||||
import { getBalenaSdk } from '../utils/lazy';
|
||||
|
||||
/**
|
||||
* Opts must be an object with the following keys:
|
||||
* app: the app this build is for (optional)
|
||||
* arch: the architecture to build for
|
||||
* deviceType: the device type to build for
|
||||
* buildEmulated
|
||||
* buildOpts: arguments to forward to docker build command
|
||||
*
|
||||
* @param {import('docker-toolbelt')} docker
|
||||
* @param {import('../utils/logger')} logger
|
||||
* @param {import('../utils/compose-types').ComposeOpts} composeOpts
|
||||
* @param {any} opts
|
||||
*/
|
||||
const buildProject = function (docker, logger, composeOpts, opts) {
|
||||
const { loadProject } = require('../utils/compose_ts');
|
||||
return loadProject(logger, composeOpts)
|
||||
.then(function (project) {
|
||||
const appType = opts.app?.application_type?.[0];
|
||||
if (
|
||||
appType != null &&
|
||||
project.descriptors.length > 1 &&
|
||||
!appType.supports_multicontainer
|
||||
) {
|
||||
logger.logWarn(
|
||||
'Target application does not support multiple containers.\n' +
|
||||
'Continuing with build, but you will not be able to deploy.',
|
||||
);
|
||||
}
|
||||
|
||||
return compose.buildProject(
|
||||
docker,
|
||||
logger,
|
||||
project.path,
|
||||
project.name,
|
||||
project.composition,
|
||||
opts.arch,
|
||||
opts.deviceType,
|
||||
opts.buildEmulated,
|
||||
opts.buildOpts,
|
||||
composeOpts.inlineLogs,
|
||||
composeOpts.convertEol,
|
||||
composeOpts.dockerfilePath,
|
||||
composeOpts.nogitignore,
|
||||
composeOpts.multiDockerignore,
|
||||
);
|
||||
})
|
||||
.then(function () {
|
||||
logger.outputDeferredMessages();
|
||||
logger.logSuccess('Build succeeded!');
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.logError('Build failed');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
export const build = {
|
||||
signature: 'build [source]',
|
||||
description: 'Build a single image or a multicontainer project locally',
|
||||
primary: true,
|
||||
help: `\
|
||||
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 provide either an application or a device-type/architecture pair to use
|
||||
the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
|
||||
|
||||
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}
|
||||
|
||||
${dockerignoreHelp}
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena build
|
||||
$ balena build ./source/
|
||||
$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated
|
||||
$ balena build --application MyApp ./source/
|
||||
$ balena build --docker /var/run/docker.sock # Linux, Mac
|
||||
$ balena build --docker //./pipe/docker_engine # Windows
|
||||
$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem\
|
||||
`,
|
||||
options: dockerUtils.appendOptions(
|
||||
compose.appendOptions([
|
||||
{
|
||||
signature: 'arch',
|
||||
parameter: 'arch',
|
||||
description: 'The architecture to build for',
|
||||
alias: 'A',
|
||||
},
|
||||
{
|
||||
signature: 'deviceType',
|
||||
parameter: 'deviceType',
|
||||
description: 'The type of device this build is for',
|
||||
alias: 'd',
|
||||
},
|
||||
{
|
||||
signature: 'application',
|
||||
parameter: 'application',
|
||||
description: 'The target balena application this build is for',
|
||||
alias: 'a',
|
||||
},
|
||||
]),
|
||||
),
|
||||
async action(params, options) {
|
||||
// compositions with many services trigger misleading warnings
|
||||
// @ts-ignore editing property that isn't typed but does exist
|
||||
require('events').defaultMaxListeners = 1000;
|
||||
|
||||
const sdk = getBalenaSdk();
|
||||
const { ExpectedError } = require('../errors');
|
||||
const { checkLoggedIn } = require('../utils/patterns');
|
||||
const { validateProjectDirectory } = require('../utils/compose_ts');
|
||||
const helpers = require('../utils/helpers');
|
||||
const Logger = require('../utils/logger');
|
||||
|
||||
const logger = Logger.getLogger();
|
||||
logger.logDebug('Parsing input...');
|
||||
|
||||
// `build` accepts `[source]` as a parameter, but compose expects it
|
||||
// as an option. swap them here
|
||||
if (options.source == null) {
|
||||
options.source = params.source;
|
||||
}
|
||||
delete params.source;
|
||||
|
||||
const { application, arch, deviceType } = options;
|
||||
|
||||
if (
|
||||
(application == null && (arch == null || deviceType == null)) ||
|
||||
(application != null && (arch != null || deviceType != null))
|
||||
) {
|
||||
throw new ExpectedError(
|
||||
'You must specify either an application or an arch/deviceType pair to build for',
|
||||
);
|
||||
}
|
||||
if (application) {
|
||||
await checkLoggedIn();
|
||||
}
|
||||
|
||||
return validateProjectDirectory(sdk, {
|
||||
dockerfilePath: options.dockerfile,
|
||||
noParentCheck: options['noparent-check'] || false,
|
||||
projectPath: options.source || '.',
|
||||
registrySecretsPath: options['registry-secrets'],
|
||||
})
|
||||
.then(function ({ dockerfilePath, registrySecrets }) {
|
||||
options.dockerfile = dockerfilePath;
|
||||
options['registry-secrets'] = registrySecrets;
|
||||
|
||||
if (arch != null && deviceType != null) {
|
||||
return [undefined, arch, deviceType];
|
||||
} else {
|
||||
return helpers
|
||||
.getAppWithArch(application)
|
||||
.then((app) => [app, app.arch, app.device_type]);
|
||||
}
|
||||
})
|
||||
|
||||
.then(function ([app, resolvedArch, resolvedDeviceType]) {
|
||||
return Promise.all([
|
||||
dockerUtils.getDocker(options),
|
||||
dockerUtils.generateBuildOpts(options),
|
||||
compose.generateOpts(options),
|
||||
]).then(([docker, buildOpts, composeOpts]) =>
|
||||
buildProject(docker, logger, composeOpts, {
|
||||
app,
|
||||
arch: resolvedArch,
|
||||
deviceType: resolvedDeviceType,
|
||||
buildEmulated: !!options.emulated,
|
||||
buildOpts,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
@ -1,153 +0,0 @@
|
||||
/*
|
||||
Copyright 2016-2017 Balena
|
||||
|
||||
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 const yes = {
|
||||
signature: 'yes',
|
||||
description: 'confirm non interactively',
|
||||
boolean: true,
|
||||
alias: 'y',
|
||||
};
|
||||
|
||||
export interface YesOption {
|
||||
yes: boolean;
|
||||
}
|
||||
|
||||
export const optionalApplication = {
|
||||
signature: 'application',
|
||||
parameter: 'application',
|
||||
description: 'application name',
|
||||
alias: ['a', 'app'],
|
||||
};
|
||||
|
||||
export const application = {
|
||||
...optionalApplication,
|
||||
required: 'You have to specify an application',
|
||||
};
|
||||
|
||||
export const optionalRelease = {
|
||||
signature: 'release',
|
||||
parameter: 'release',
|
||||
description: 'release id',
|
||||
alias: 'r',
|
||||
};
|
||||
|
||||
export const optionalDevice = {
|
||||
signature: 'device',
|
||||
parameter: 'device',
|
||||
description: 'device uuid',
|
||||
alias: 'd',
|
||||
};
|
||||
|
||||
export const optionalDeviceApiKey = {
|
||||
signature: 'deviceApiKey',
|
||||
description:
|
||||
'custom device key - note that this is only supported on balenaOS 2.0.3+',
|
||||
parameter: 'device-api-key',
|
||||
alias: 'k',
|
||||
};
|
||||
|
||||
export const optionalDeviceType = {
|
||||
signature: 'deviceType',
|
||||
description: 'device type slug',
|
||||
parameter: 'device-type',
|
||||
};
|
||||
|
||||
export const optionalOsVersion = {
|
||||
signature: 'version',
|
||||
description: 'a balenaOS version',
|
||||
parameter: 'version',
|
||||
};
|
||||
|
||||
export type OptionalOsVersionOption = Partial<OsVersionOption>;
|
||||
|
||||
export const osVersion = {
|
||||
...exports.optionalOsVersion,
|
||||
required: 'You have to specify an exact os version',
|
||||
};
|
||||
|
||||
export interface OsVersionOption {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const booleanDevice = {
|
||||
signature: 'device',
|
||||
description: 'device',
|
||||
boolean: true,
|
||||
alias: 'd',
|
||||
};
|
||||
|
||||
export const osVersionOrSemver = {
|
||||
signature: 'version',
|
||||
description: `\
|
||||
exact version number, or a valid semver range,
|
||||
or 'latest' (includes pre-releases),
|
||||
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' (will show the interactive menu)\
|
||||
`,
|
||||
parameter: 'version',
|
||||
};
|
||||
|
||||
export const network = {
|
||||
signature: 'network',
|
||||
parameter: 'network',
|
||||
description: 'network type',
|
||||
alias: 'n',
|
||||
};
|
||||
|
||||
export const wifiSsid = {
|
||||
signature: 'ssid',
|
||||
parameter: 'ssid',
|
||||
description: 'wifi ssid, if network is wifi',
|
||||
alias: 's',
|
||||
};
|
||||
|
||||
export const wifiKey = {
|
||||
signature: 'key',
|
||||
parameter: 'key',
|
||||
description: 'wifi key, if network is wifi',
|
||||
alias: 'k',
|
||||
};
|
||||
|
||||
export const forceUpdateLock = {
|
||||
signature: 'force',
|
||||
description: 'force action if the update lock is set',
|
||||
boolean: true,
|
||||
alias: 'f',
|
||||
};
|
||||
|
||||
export const drive = {
|
||||
signature: 'drive',
|
||||
description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \
|
||||
Careful with this as you can erase your hard drive. \
|
||||
Check \`balena util available-drives\` for available options.`,
|
||||
parameter: 'drive',
|
||||
alias: 'd',
|
||||
};
|
||||
|
||||
export const advancedConfig = {
|
||||
signature: 'advanced',
|
||||
description: 'show advanced configuration options',
|
||||
boolean: true,
|
||||
alias: 'v',
|
||||
};
|
||||
|
||||
export const hostOSAccess = {
|
||||
signature: 'host',
|
||||
boolean: true,
|
||||
description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)',
|
||||
alias: 's',
|
||||
};
|
@ -1,318 +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 * as dockerUtils from '../utils/docker';
|
||||
import * as compose from '../utils/compose';
|
||||
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
|
||||
import { ExpectedError } from '../errors';
|
||||
import { getBalenaSdk, getChalk } from '../utils/lazy';
|
||||
|
||||
/**
|
||||
* Opts must be an object with the following keys:
|
||||
* app: the application instance to deploy to
|
||||
* image: the image to deploy; optional
|
||||
* dockerfilePath: name of an alternative Dockerfile; optional
|
||||
* shouldPerformBuild
|
||||
* shouldUploadLogs
|
||||
* buildEmulated
|
||||
* buildOpts: arguments to forward to docker build command
|
||||
*
|
||||
* @param {any} docker
|
||||
* @param {import('../utils/logger')} logger
|
||||
* @param {import('../utils/compose-types').ComposeOpts} composeOpts
|
||||
* @param {any} opts
|
||||
*/
|
||||
const deployProject = function (docker, logger, composeOpts, opts) {
|
||||
const Bluebird = require('bluebird');
|
||||
const _ = require('lodash');
|
||||
const doodles = require('resin-doodles');
|
||||
const sdk = getBalenaSdk();
|
||||
const {
|
||||
deployProject: $deployProject,
|
||||
loadProject,
|
||||
} = require('../utils/compose_ts');
|
||||
|
||||
return loadProject(logger, composeOpts, opts.image)
|
||||
.then(function (project) {
|
||||
if (
|
||||
project.descriptors.length > 1 &&
|
||||
!opts.app.application_type?.[0]?.supports_multicontainer
|
||||
) {
|
||||
throw new ExpectedError(
|
||||
'Target application does not support multiple containers. Aborting!',
|
||||
);
|
||||
}
|
||||
|
||||
// find which services use images that already exist locally
|
||||
return (
|
||||
Bluebird.map(project.descriptors, function (d) {
|
||||
// unconditionally build (or pull) if explicitly requested
|
||||
if (opts.shouldPerformBuild) {
|
||||
return d;
|
||||
}
|
||||
return docker
|
||||
.getImage(typeof d.image === 'string' ? d.image : d.image.tag)
|
||||
.inspect()
|
||||
.return(d.serviceName)
|
||||
.catchReturn();
|
||||
})
|
||||
.filter((d) => !!d)
|
||||
.then(function (servicesToSkip) {
|
||||
// multibuild takes in a composition and always attempts to
|
||||
// build or pull all services. we workaround that here by
|
||||
// passing a modified composition.
|
||||
const compositionToBuild = _.cloneDeep(project.composition);
|
||||
compositionToBuild.services = _.omit(
|
||||
compositionToBuild.services,
|
||||
servicesToSkip,
|
||||
);
|
||||
if (_.size(compositionToBuild.services) === 0) {
|
||||
logger.logInfo(
|
||||
'Everything is up to date (use --build to force a rebuild)',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
return compose
|
||||
.buildProject(
|
||||
docker,
|
||||
logger,
|
||||
project.path,
|
||||
project.name,
|
||||
compositionToBuild,
|
||||
opts.app.arch,
|
||||
opts.app.device_type,
|
||||
opts.buildEmulated,
|
||||
opts.buildOpts,
|
||||
composeOpts.inlineLogs,
|
||||
composeOpts.convertEol,
|
||||
composeOpts.dockerfilePath,
|
||||
composeOpts.nogitignore,
|
||||
composeOpts.multiDockerignore,
|
||||
)
|
||||
.then((builtImages) => _.keyBy(builtImages, 'serviceName'));
|
||||
})
|
||||
.then((builtImages) =>
|
||||
project.descriptors.map(
|
||||
(d) =>
|
||||
builtImages[d.serviceName] ?? {
|
||||
serviceName: d.serviceName,
|
||||
name: typeof d.image === 'string' ? d.image : d.image.tag,
|
||||
logs: 'Build skipped; image for service already exists.',
|
||||
props: {},
|
||||
},
|
||||
),
|
||||
)
|
||||
// @ts-ignore slightly different return types of partial vs non-partial release
|
||||
.then(function (images) {
|
||||
if (opts.app.application_type?.[0]?.is_legacy) {
|
||||
const { deployLegacy } = require('../utils/deploy-legacy');
|
||||
|
||||
const msg = getChalk().yellow(
|
||||
'Target application requires legacy deploy method.',
|
||||
);
|
||||
logger.logWarn(msg);
|
||||
|
||||
return Bluebird.join(
|
||||
docker,
|
||||
logger,
|
||||
sdk.auth.getToken(),
|
||||
sdk.auth.whoami(),
|
||||
sdk.settings.get('balenaUrl'),
|
||||
{
|
||||
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
|
||||
appName: opts.appName,
|
||||
imageName: images[0].name,
|
||||
buildLogs: images[0].logs,
|
||||
shouldUploadLogs: opts.shouldUploadLogs,
|
||||
},
|
||||
deployLegacy,
|
||||
).then((releaseId) =>
|
||||
// @ts-ignore releaseId should be inferred as a number because that's what deployLegacy is
|
||||
// typed as returning but the .js type-checking doesn't manage to infer it correctly due to
|
||||
// Promise.join typings
|
||||
sdk.models.release.get(releaseId, { $select: ['commit'] }),
|
||||
);
|
||||
}
|
||||
return Promise.all([
|
||||
sdk.auth.getUserId(),
|
||||
sdk.auth.getToken(),
|
||||
sdk.settings.get('apiUrl'),
|
||||
]).then(([userId, auth, apiEndpoint]) =>
|
||||
$deployProject(
|
||||
docker,
|
||||
logger,
|
||||
project.composition,
|
||||
images,
|
||||
opts.app.id,
|
||||
userId,
|
||||
`Bearer ${auth}`,
|
||||
apiEndpoint,
|
||||
!opts.shouldUploadLogs,
|
||||
),
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(function (release) {
|
||||
logger.outputDeferredMessages();
|
||||
logger.logSuccess('Deploy succeeded!');
|
||||
logger.logSuccess(`Release: ${release.commit}`);
|
||||
console.log();
|
||||
console.log(doodles.getDoodle()); // Show charlie
|
||||
console.log();
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.logError('Deploy failed');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
export const deploy = {
|
||||
signature: 'deploy <appName> [image]',
|
||||
description:
|
||||
'Deploy a single image or a multicontainer project to a balena application',
|
||||
help: `\
|
||||
Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
|
||||
|
||||
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.)
|
||||
|
||||
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. 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 an app on which you're a collaborator, use
|
||||
\`balena deploy <appOwnerUsername>/<appName>\`.
|
||||
|
||||
When --build is used, all options supported by \`balena build\` are also supported
|
||||
by this command.
|
||||
|
||||
${registrySecretsHelp}
|
||||
|
||||
${dockerignoreHelp}
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena deploy myApp
|
||||
$ balena deploy myApp --build --source myBuildDir/
|
||||
$ balena deploy myApp myApp/myImage\
|
||||
`,
|
||||
permission: 'user',
|
||||
primary: true,
|
||||
options: dockerUtils.appendOptions(
|
||||
compose.appendOptions([
|
||||
{
|
||||
signature: 'source',
|
||||
parameter: 'source',
|
||||
description:
|
||||
'Specify an alternate source directory; default is the working directory',
|
||||
alias: 's',
|
||||
},
|
||||
{
|
||||
signature: 'build',
|
||||
boolean: true,
|
||||
description: 'Force a rebuild before deploy',
|
||||
alias: 'b',
|
||||
},
|
||||
{
|
||||
signature: 'nologupload',
|
||||
description:
|
||||
"Don't upload build logs to the dashboard with image (if building)",
|
||||
boolean: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
async action(params, options) {
|
||||
// compositions with many services trigger misleading warnings
|
||||
// @ts-ignore editing property that isn't typed but does exist
|
||||
require('events').defaultMaxListeners = 1000;
|
||||
const sdk = getBalenaSdk();
|
||||
const {
|
||||
getRegistrySecrets,
|
||||
validateProjectDirectory,
|
||||
} = require('../utils/compose_ts');
|
||||
const helpers = require('../utils/helpers');
|
||||
const Logger = require('../utils/logger');
|
||||
|
||||
const logger = Logger.getLogger();
|
||||
logger.logDebug('Parsing input...');
|
||||
|
||||
// when Capitano converts a positional parameter (but not an option)
|
||||
// to a number, the original value is preserved with the _raw suffix
|
||||
let { appName, appName_raw, image } = params;
|
||||
|
||||
// look into "balena build" options if appName isn't given
|
||||
appName = appName_raw || appName || options.application;
|
||||
delete options.application;
|
||||
|
||||
if (appName == null) {
|
||||
throw new ExpectedError(
|
||||
'Please specify the name of the application to deploy',
|
||||
);
|
||||
}
|
||||
|
||||
if (image != null && options.build) {
|
||||
throw new ExpectedError(
|
||||
'Build option is not applicable when specifying an image',
|
||||
);
|
||||
}
|
||||
|
||||
if (image) {
|
||||
const registrySecrets = await getRegistrySecrets(
|
||||
sdk,
|
||||
options['registry-secrets'],
|
||||
);
|
||||
options['registry-secrets'] = registrySecrets;
|
||||
} else {
|
||||
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 app = await helpers.getAppWithArch(appName);
|
||||
|
||||
const [docker, buildOpts, composeOpts] = await Promise.all([
|
||||
dockerUtils.getDocker(options),
|
||||
dockerUtils.generateBuildOpts(options),
|
||||
compose.generateOpts(options),
|
||||
]);
|
||||
await deployProject(docker, logger, composeOpts, {
|
||||
app,
|
||||
appName, // may be prefixed by 'owner/', unlike app.app_name
|
||||
image,
|
||||
shouldPerformBuild: !!options.build,
|
||||
shouldUploadLogs: !options.nologupload,
|
||||
buildEmulated: !!options.emulated,
|
||||
buildOpts,
|
||||
});
|
||||
},
|
||||
};
|
@ -1,190 +0,0 @@
|
||||
/*
|
||||
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 * as _ from 'lodash';
|
||||
|
||||
import * as capitano from 'capitano';
|
||||
import * as columnify from 'columnify';
|
||||
import * as messages from '../utils/messages';
|
||||
import { getManualSortCompareFunction } from '../utils/helpers';
|
||||
import { exitWithExpectedError } from '../errors';
|
||||
import { getOclifHelpLinePairs } from './help_ts';
|
||||
|
||||
const parse = (object) =>
|
||||
_.map(object, function (item) {
|
||||
// Hacky way to determine if an object is
|
||||
// a function or a command
|
||||
let signature;
|
||||
if (item.alias != null) {
|
||||
signature = item.toString();
|
||||
} else {
|
||||
signature = item.signature.toString();
|
||||
}
|
||||
|
||||
return [signature, item.description];
|
||||
});
|
||||
|
||||
const indent = function (text) {
|
||||
text = _.map(text.split('\n'), (line) => ' ' + line);
|
||||
return text.join('\n');
|
||||
};
|
||||
|
||||
const print = (usageDescriptionPairs) =>
|
||||
console.log(
|
||||
indent(
|
||||
columnify(_.fromPairs(usageDescriptionPairs), {
|
||||
showHeaders: false,
|
||||
minWidth: 35,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const manuallySortedPrimaryCommands = [
|
||||
'help',
|
||||
'login',
|
||||
'push',
|
||||
'logs',
|
||||
'ssh',
|
||||
'apps',
|
||||
'app',
|
||||
'devices',
|
||||
'device',
|
||||
'tunnel',
|
||||
'preload',
|
||||
'build',
|
||||
'deploy',
|
||||
'join',
|
||||
'leave',
|
||||
'local scan',
|
||||
];
|
||||
|
||||
const general = function (_params, options, done) {
|
||||
console.log('Usage: balena [COMMAND] [OPTIONS]\n');
|
||||
|
||||
console.log('Primary commands:\n');
|
||||
|
||||
// We do not want the wildcard command
|
||||
// to be printed in the help screen.
|
||||
const commands = capitano.state.commands.filter(
|
||||
(command) => !command.hidden && !command.isWildcard(),
|
||||
);
|
||||
|
||||
const capitanoCommands = _.groupBy(commands, function (command) {
|
||||
if (command.primary) {
|
||||
return 'primary';
|
||||
}
|
||||
return 'secondary';
|
||||
});
|
||||
|
||||
return getOclifHelpLinePairs()
|
||||
.then(function (oclifHelpLinePairs) {
|
||||
const primaryHelpLinePairs = parse(capitanoCommands.primary)
|
||||
.concat(oclifHelpLinePairs.primary)
|
||||
.sort(
|
||||
getManualSortCompareFunction(manuallySortedPrimaryCommands, function (
|
||||
[signature],
|
||||
manualItem,
|
||||
) {
|
||||
return (
|
||||
signature === manualItem || signature.startsWith(`${manualItem} `)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const secondaryHelpLinePairs = parse(capitanoCommands.secondary)
|
||||
.concat(oclifHelpLinePairs.secondary)
|
||||
.sort();
|
||||
|
||||
print(primaryHelpLinePairs);
|
||||
|
||||
if (options.verbose) {
|
||||
console.log('\nAdditional commands:\n');
|
||||
print(secondaryHelpLinePairs);
|
||||
} else {
|
||||
console.log(
|
||||
'\nRun `balena help --verbose` to list additional commands',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_.isEmpty(capitano.state.globalOptions)) {
|
||||
console.log('\nGlobal Options:\n');
|
||||
print(parse(capitano.state.globalOptions).sort());
|
||||
}
|
||||
console.log(indent('--debug\n'));
|
||||
|
||||
console.log(messages.help);
|
||||
|
||||
return done();
|
||||
})
|
||||
.catch(done);
|
||||
};
|
||||
|
||||
const commandHelp = (params, _options, done) =>
|
||||
capitano.state.getMatchCommand(params.command, function (error, command) {
|
||||
if (error != null) {
|
||||
return done(error);
|
||||
}
|
||||
|
||||
if (command == null || command.isWildcard()) {
|
||||
exitWithExpectedError(`Command not found: ${params.command}`);
|
||||
}
|
||||
|
||||
console.log(`Usage: ${command.signature}`);
|
||||
|
||||
if (command.help != null) {
|
||||
console.log(`\n${command.help}`);
|
||||
} else if (command.description != null) {
|
||||
console.log(`\n${_.capitalize(command.description)}`);
|
||||
}
|
||||
|
||||
if (!_.isEmpty(command.options)) {
|
||||
console.log('\nOptions:\n');
|
||||
print(parse(command.options).sort());
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
return done();
|
||||
});
|
||||
|
||||
export const help = {
|
||||
signature: 'help [command...]',
|
||||
description: 'show help',
|
||||
help: `\
|
||||
Get detailed help for an specific command.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena help apps
|
||||
$ balena help os download\
|
||||
`,
|
||||
primary: true,
|
||||
options: [
|
||||
{
|
||||
signature: 'verbose',
|
||||
description: 'show additional commands',
|
||||
boolean: true,
|
||||
alias: 'v',
|
||||
},
|
||||
],
|
||||
action(params, options, done) {
|
||||
if (params.command != null) {
|
||||
return commandHelp(params, options, done);
|
||||
} else {
|
||||
return general(params, options, done);
|
||||
}
|
||||
},
|
||||
};
|
@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 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.
|
||||
* 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 * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
import Command from '../command';
|
||||
|
||||
import { capitanoizeOclifUsage } from '../utils/oclif-utils';
|
||||
|
||||
export async function getOclifHelpLinePairs() {
|
||||
const { convertedCommands } = await import('../preparser');
|
||||
const primary: Array<[string, string]> = [];
|
||||
const secondary: Array<[string, string]> = [];
|
||||
|
||||
for (const convertedCmd of convertedCommands) {
|
||||
const [topic, cmd] = convertedCmd.split(':');
|
||||
const pathComponents = ['..', 'actions-oclif', topic];
|
||||
if (cmd) {
|
||||
pathComponents.push(cmd);
|
||||
}
|
||||
|
||||
const cmdModule = await import(path.join(...pathComponents));
|
||||
const command: typeof Command = cmdModule.default;
|
||||
|
||||
if (!command.hidden) {
|
||||
if (command.primary) {
|
||||
primary.push(getCmdUsageDescriptionLinePair(command));
|
||||
} else {
|
||||
secondary.push(getCmdUsageDescriptionLinePair(command));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
|
||||
const usage = capitanoizeOclifUsage(cmd.usage);
|
||||
let description = '';
|
||||
// note: [^] matches any characters (including line breaks), achieving the
|
||||
// same effect as the 's' regex flag which is only supported by Node 9+
|
||||
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
|
||||
if (matches && matches.length > 1) {
|
||||
description = _.trimEnd(matches[1], '.');
|
||||
// Only do .lowerFirst() if the second char is not uppercase (e.g. for 'SSH');
|
||||
if (description[1] !== description[1]?.toUpperCase()) {
|
||||
description = _.lowerFirst(description);
|
||||
}
|
||||
}
|
||||
return [usage, description];
|
||||
}
|
@ -1,467 +0,0 @@
|
||||
/*
|
||||
Copyright 2016-2020 Balena
|
||||
|
||||
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 * as _ from 'lodash';
|
||||
import { getBalenaSdk, getVisuals, getCliForm } from '../utils/lazy';
|
||||
import * as dockerUtils from '../utils/docker';
|
||||
|
||||
const isCurrent = (commit) => commit === 'latest' || commit === 'current';
|
||||
|
||||
/** @type {any} */
|
||||
const applicationExpandOptions = {
|
||||
owns__release: {
|
||||
$select: ['id', 'commit', 'end_timestamp', 'composition'],
|
||||
$orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }],
|
||||
$expand: {
|
||||
contains__image: {
|
||||
$select: ['image'],
|
||||
$expand: {
|
||||
image: {
|
||||
$select: ['image_size', 'is_stored_at__image_location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
$filter: {
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let allDeviceTypes;
|
||||
const getDeviceTypes = function () {
|
||||
const Bluebird = require('bluebird');
|
||||
if (allDeviceTypes !== undefined) {
|
||||
return Bluebird.resolve(allDeviceTypes);
|
||||
}
|
||||
const balena = getBalenaSdk();
|
||||
return balena.models.config
|
||||
.getDeviceTypes()
|
||||
.then((deviceTypes) => _.sortBy(deviceTypes, 'name'))
|
||||
.tap((dt) => {
|
||||
allDeviceTypes = dt;
|
||||
});
|
||||
};
|
||||
|
||||
const getDeviceTypesWithSameArch = function (deviceTypeSlug) {
|
||||
return getDeviceTypes().then(function (deviceTypes) {
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
const getApplicationsWithSuccessfulBuilds = function (deviceType) {
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
return getDeviceTypesWithSameArch(deviceType).then((deviceTypes) => {
|
||||
/** @type {import('balena-sdk').PineOptionsFor<import('balena-sdk').Application>} */
|
||||
const options = {
|
||||
$filter: {
|
||||
device_type: {
|
||||
$in: deviceTypes,
|
||||
},
|
||||
owns__release: {
|
||||
$any: {
|
||||
$alias: 'r',
|
||||
$expr: {
|
||||
r: {
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
$expand: applicationExpandOptions,
|
||||
$select: [
|
||||
'id',
|
||||
'app_name',
|
||||
'device_type',
|
||||
'commit',
|
||||
'should_track_latest_release',
|
||||
],
|
||||
$orderby: 'app_name asc',
|
||||
};
|
||||
return balena.pine.get({
|
||||
resource: 'my_application',
|
||||
options,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const selectApplication = function (deviceType) {
|
||||
const visuals = getVisuals();
|
||||
const { exitWithExpectedError } = require('../errors');
|
||||
|
||||
const applicationInfoSpinner = new visuals.Spinner(
|
||||
'Downloading list of applications and releases.',
|
||||
);
|
||||
applicationInfoSpinner.start();
|
||||
|
||||
return getApplicationsWithSuccessfulBuilds(deviceType).then(function (
|
||||
applications,
|
||||
) {
|
||||
applicationInfoSpinner.stop();
|
||||
if (applications.length === 0) {
|
||||
exitWithExpectedError(
|
||||
`You have no apps with successful releases for a '${deviceType}' device type.`,
|
||||
);
|
||||
}
|
||||
return getCliForm().ask({
|
||||
message: 'Select an application',
|
||||
type: 'list',
|
||||
choices: applications.map((app) => ({
|
||||
name: app.app_name,
|
||||
value: app,
|
||||
})),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const selectApplicationCommit = function (releases) {
|
||||
const { exitWithExpectedError } = require('../errors');
|
||||
|
||||
if (releases.length === 0) {
|
||||
exitWithExpectedError('This application has no successful releases.');
|
||||
}
|
||||
const DEFAULT_CHOICE = { name: 'current', value: 'current' };
|
||||
const choices = [DEFAULT_CHOICE].concat(
|
||||
releases.map((release) => ({
|
||||
name: `${release.end_timestamp} - ${release.commit}`,
|
||||
value: release.commit,
|
||||
})),
|
||||
);
|
||||
return getCliForm().ask({
|
||||
message: 'Select a release',
|
||||
type: 'list',
|
||||
default: 'current',
|
||||
choices,
|
||||
});
|
||||
};
|
||||
|
||||
const offerToDisableAutomaticUpdates = function (
|
||||
application,
|
||||
commit,
|
||||
pinDevice,
|
||||
) {
|
||||
const Bluebird = require('bluebird');
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
if (
|
||||
isCurrent(commit) ||
|
||||
!application.should_track_latest_release ||
|
||||
pinDevice
|
||||
) {
|
||||
return Bluebird.resolve();
|
||||
}
|
||||
const message = `\
|
||||
|
||||
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
|
||||
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 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 application now?\
|
||||
`;
|
||||
return getCliForm()
|
||||
.ask({
|
||||
message,
|
||||
type: 'confirm',
|
||||
})
|
||||
.then(function (update) {
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
return balena.pine.patch({
|
||||
resource: 'application',
|
||||
id: application.id,
|
||||
body: {
|
||||
should_track_latest_release: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('balena-sdk').BalenaSDK} balenaSdk
|
||||
* @param {string | number} appId
|
||||
* @returns {Promise<import('balena-sdk').Application>}
|
||||
*/
|
||||
async function getAppWithReleases(balenaSdk, appId) {
|
||||
return balenaSdk.models.application.get(appId, {
|
||||
$expand: applicationExpandOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareAndPreload(preloader, balenaSdk, options) {
|
||||
const { ExpectedError } = require('../errors');
|
||||
|
||||
await preloader.prepare();
|
||||
preloader.config = { deviceType: 'intel-nuc' };
|
||||
|
||||
const application = options.appId
|
||||
? await getAppWithReleases(balenaSdk, options.appId)
|
||||
: await selectApplication(preloader.config.deviceType);
|
||||
|
||||
/** @type {string} commit hash or the strings 'latest' or 'current' */
|
||||
let commit;
|
||||
|
||||
// Use the commit given as --commit or show an interactive commit selection menu
|
||||
if (options.commit) {
|
||||
if (isCurrent(options.commit)) {
|
||||
if (!application.commit) {
|
||||
throw new Error(
|
||||
`Unexpected empty commit hash for app ID "${application.id}"`,
|
||||
);
|
||||
}
|
||||
// handle `--commit current` (and its `--commit latest` synonym)
|
||||
commit = 'latest';
|
||||
} else {
|
||||
const release = _.find(application.owns__release, (r) =>
|
||||
r.commit.startsWith(options.commit),
|
||||
);
|
||||
if (!release) {
|
||||
throw new ExpectedError(
|
||||
`There is no release matching commit "${options.commit}"`,
|
||||
);
|
||||
}
|
||||
commit = release.commit;
|
||||
}
|
||||
} else {
|
||||
// this could have the value 'current'
|
||||
commit = await selectApplicationCommit(application.owns__release);
|
||||
}
|
||||
|
||||
await preloader.setAppIdAndCommit(
|
||||
application.id,
|
||||
isCurrent(commit) ? application.commit : commit,
|
||||
);
|
||||
|
||||
// Propose to disable automatic app updates if the commit is not the current release
|
||||
await offerToDisableAutomaticUpdates(application, commit, options.pinDevice);
|
||||
|
||||
// All options are ready: preload the image.
|
||||
await preloader.preload();
|
||||
}
|
||||
|
||||
const preloadOptions = dockerUtils.appendConnectionOptions([
|
||||
{
|
||||
signature: 'app',
|
||||
parameter: 'appId',
|
||||
description: 'Name, slug or numeric ID of the application to preload',
|
||||
alias: 'a',
|
||||
},
|
||||
{
|
||||
signature: 'commit',
|
||||
parameter: 'hash',
|
||||
description: `\
|
||||
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 .\
|
||||
`,
|
||||
alias: 'c',
|
||||
},
|
||||
{
|
||||
signature: 'splash-image',
|
||||
parameter: 'splashImage.png',
|
||||
description: 'path to a png image to replace the splash screen',
|
||||
alias: 's',
|
||||
},
|
||||
{
|
||||
signature: 'dont-check-arch',
|
||||
boolean: true,
|
||||
description:
|
||||
'Disables check for matching architecture in image and application',
|
||||
},
|
||||
{
|
||||
signature: 'pin-device-to-release',
|
||||
boolean: true,
|
||||
description:
|
||||
'Pin the preloaded device to the preloaded release on provision',
|
||||
alias: 'p',
|
||||
},
|
||||
{
|
||||
signature: 'add-certificate',
|
||||
parameter: 'certificate.crt',
|
||||
description: `\
|
||||
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
|
||||
The file name must end with '.crt' and must not be already contained in the preloader's
|
||||
/etc/ssl/certs folder.
|
||||
Can be repeated to add multiple certificates.\
|
||||
`,
|
||||
},
|
||||
]);
|
||||
// Remove dockerPort `-p` alias as it conflicts with pin-device-to-release
|
||||
delete _.find(preloadOptions, { signature: 'dockerPort' }).alias;
|
||||
|
||||
export const preload = {
|
||||
signature: 'preload <image>',
|
||||
description: 'preload an app on a disk image (or Edison zip archive)',
|
||||
help: `\
|
||||
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 application, as it was
|
||||
preloaded.
|
||||
|
||||
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
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png
|
||||
$ balena preload balena.img\
|
||||
`,
|
||||
permission: 'user',
|
||||
primary: true,
|
||||
options: preloadOptions,
|
||||
async action(params, options) {
|
||||
const balena = getBalenaSdk();
|
||||
const balenaPreload = require('balena-preload');
|
||||
const visuals = getVisuals();
|
||||
const nodeCleanup = require('node-cleanup');
|
||||
const { ExpectedError, instanceOf } = require('../errors');
|
||||
|
||||
const progressBars = {};
|
||||
|
||||
const progressHandler = function (event) {
|
||||
let progressBar = progressBars[event.name];
|
||||
if (!progressBar) {
|
||||
progressBar = progressBars[event.name] = new visuals.Progress(
|
||||
event.name,
|
||||
);
|
||||
}
|
||||
return progressBar.update({ percentage: event.percentage });
|
||||
};
|
||||
|
||||
const spinners = {};
|
||||
|
||||
const spinnerHandler = function (event) {
|
||||
let spinner = spinners[event.name];
|
||||
if (!spinner) {
|
||||
spinner = spinners[event.name] = new visuals.Spinner(event.name);
|
||||
}
|
||||
if (event.action === 'start') {
|
||||
return spinner.start();
|
||||
} else {
|
||||
console.log();
|
||||
return spinner.stop();
|
||||
}
|
||||
};
|
||||
|
||||
options.commit = isCurrent(options.commit) ? 'latest' : options.commit;
|
||||
options.image = params.image;
|
||||
options.appId = options.app;
|
||||
delete options.app;
|
||||
|
||||
options.splashImage = options['splash-image'];
|
||||
delete options['splash-image'];
|
||||
|
||||
options.dontCheckArch = options['dont-check-arch'] || false;
|
||||
delete options['dont-check-arch'];
|
||||
if (options.dontCheckArch && !options.appId) {
|
||||
throw new ExpectedError(
|
||||
'You need to specify an app id if you disable the architecture check.',
|
||||
);
|
||||
}
|
||||
|
||||
options.pinDevice = options['pin-device-to-release'] || false;
|
||||
delete options['pin-device-to-release'];
|
||||
|
||||
let certificates;
|
||||
if (Array.isArray(options['add-certificate'])) {
|
||||
certificates = options['add-certificate'];
|
||||
} else if (options['add-certificate'] === undefined) {
|
||||
certificates = [];
|
||||
} else {
|
||||
certificates = [options['add-certificate']];
|
||||
}
|
||||
for (let certificate of certificates) {
|
||||
if (!certificate.endsWith('.crt')) {
|
||||
throw new ExpectedError('Certificate file name must end with ".crt"');
|
||||
}
|
||||
}
|
||||
|
||||
// Get a configured dockerode instance
|
||||
const docker = await dockerUtils.getDocker(options);
|
||||
const preloader = new balenaPreload.Preloader(
|
||||
null,
|
||||
docker,
|
||||
options.appId,
|
||||
options.commit,
|
||||
options.image,
|
||||
options.splashImage,
|
||||
options.proxy,
|
||||
options.dontCheckArch,
|
||||
options.pinDevice,
|
||||
certificates,
|
||||
);
|
||||
|
||||
let gotSignal = false;
|
||||
|
||||
nodeCleanup(function (_exitCode, signal) {
|
||||
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);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
preloader.stderr.pipe(process.stderr);
|
||||
}
|
||||
|
||||
preloader.on('progress', progressHandler);
|
||||
preloader.on('spinner', spinnerHandler);
|
||||
|
||||
try {
|
||||
await new Promise(function (resolve, reject) {
|
||||
preloader.on('error', reject);
|
||||
resolve(prepareAndPreload(preloader, balena, options));
|
||||
});
|
||||
} catch (err) {
|
||||
if (instanceOf(err, balena.errors.BalenaError)) {
|
||||
const code = err.code ? `(${err.code})` : '';
|
||||
throw new ExpectedError(`${err.message} ${code}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!gotSignal) {
|
||||
await preloader.cleanup();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
@ -1,85 +0,0 @@
|
||||
/*
|
||||
Copyright 2016-2020 Balena
|
||||
|
||||
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 * as capitano from 'capitano';
|
||||
import * as actions from './actions';
|
||||
import * as events from './events';
|
||||
import { promisify } from 'util';
|
||||
|
||||
capitano.permission('user', (done) =>
|
||||
require('./utils/patterns').checkLoggedIn().then(done, done),
|
||||
);
|
||||
|
||||
capitano.command({
|
||||
signature: '*',
|
||||
action(_params, _options, done) {
|
||||
capitano.execute({ command: 'help' }, done);
|
||||
process.exitCode = process.exitCode || 1;
|
||||
},
|
||||
});
|
||||
|
||||
capitano.globalOption({
|
||||
signature: 'help',
|
||||
boolean: true,
|
||||
alias: 'h',
|
||||
});
|
||||
|
||||
capitano.globalOption({
|
||||
signature: 'version',
|
||||
boolean: true,
|
||||
alias: 'v',
|
||||
});
|
||||
|
||||
// ---------- Help Module ----------
|
||||
capitano.command(actions.help.help);
|
||||
|
||||
// ---------- Preload Module ----------
|
||||
capitano.command(actions.preload);
|
||||
|
||||
// ------------ Local build and deploy -------
|
||||
capitano.command(actions.build);
|
||||
capitano.command(actions.deploy);
|
||||
|
||||
export function run(argv: string[]) {
|
||||
const cli = capitano.parse(argv.slice(2));
|
||||
const runCommand = function () {
|
||||
const capitanoExecuteAsync = promisify(capitano.execute);
|
||||
if (cli.global?.help) {
|
||||
return capitanoExecuteAsync({
|
||||
command: `help ${cli.command ?? ''}`,
|
||||
});
|
||||
} else {
|
||||
return capitanoExecuteAsync(cli);
|
||||
}
|
||||
};
|
||||
|
||||
const trackCommand = function () {
|
||||
const getMatchCommandAsync = promisify(capitano.state.getMatchCommand);
|
||||
return getMatchCommandAsync(cli.command).then(function (command) {
|
||||
// cmdSignature is literally a string like, for example:
|
||||
// "push <applicationOrDevice>"
|
||||
// ("applicationOrDevice" is NOT replaced with its actual value)
|
||||
// In case of failures like an nonexistent or invalid command,
|
||||
// command.signature.toString() returns '*'
|
||||
const cmdSignature = command.signature.toString();
|
||||
return events.trackCommand(cmdSignature);
|
||||
});
|
||||
};
|
||||
|
||||
return Promise.all([trackCommand(), runCommand()]).catch(
|
||||
require('./errors').handleError,
|
||||
);
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 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.
|
||||
* 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 { Main } from '@oclif/command';
|
||||
|
||||
import { trackPromise } from './hooks/prerun/track';
|
||||
|
||||
class CustomMain extends Main {
|
||||
protected _helpOverride(): boolean {
|
||||
// Disable oclif's default handler for the 'version' command
|
||||
if (['-v', '--version', 'version'].includes(this.argv[0])) {
|
||||
return false;
|
||||
} else {
|
||||
return super._helpOverride();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import type { AppOptions } from './preparser';
|
||||
|
||||
/**
|
||||
* oclif CLI entrypoint
|
||||
*/
|
||||
export async function run(command: string[], options: AppOptions) {
|
||||
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) {
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
await Promise.all([trackPromise, runPromise]);
|
||||
} catch (err) {
|
||||
await (await import('./errors')).handleError(err);
|
||||
}
|
||||
}
|
185
lib/app.ts
185
lib/app.ts
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Balena Ltd.
|
||||
* Copyright 2019-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,73 +15,138 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as packageJSON from '../package.json';
|
||||
import { CliSettings } from './utils/bootstrap';
|
||||
import { onceAsync, stripIndent } from './utils/lazy';
|
||||
|
||||
/**
|
||||
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
|
||||
* call this function.
|
||||
* Sentry.io setup
|
||||
* @see https://docs.sentry.io/error-reporting/quickstart/?platform=node
|
||||
*/
|
||||
export const setupSentry = onceAsync(async () => {
|
||||
const config = await import('./config');
|
||||
const Sentry = await import('@sentry/node');
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
release: packageJSON.version,
|
||||
});
|
||||
Sentry.configureScope((scope) => {
|
||||
scope.setExtras({
|
||||
is_pkg: !!(process as any).pkg,
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
});
|
||||
});
|
||||
return Sentry.getCurrentHub();
|
||||
});
|
||||
|
||||
async function checkNodeVersion() {
|
||||
const validNodeVersions = packageJSON.engines.node;
|
||||
if (!(await import('semver')).satisfies(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/
|
||||
------------------------------------------------------------------------------
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup balena-sdk options that are shared with imported packages */
|
||||
function setupBalenaSdkSharedOptions(settings: CliSettings) {
|
||||
const BalenaSdk = require('balena-sdk') as typeof import('balena-sdk');
|
||||
BalenaSdk.setSharedOptions({
|
||||
apiUrl: settings.get<string>('apiUrl'),
|
||||
dataDirectory: settings.get<string>('dataDirectory'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Addresses the console warning:
|
||||
* (node:49500) MaxListenersExceededWarning: Possible EventEmitter memory
|
||||
* leak detected. 11 error listeners added. Use emitter.setMaxListeners() to
|
||||
* increase limit
|
||||
*/
|
||||
export function setMaxListeners(maxListeners: number) {
|
||||
require('events').EventEmitter.defaultMaxListeners = maxListeners;
|
||||
}
|
||||
|
||||
/** Selected CLI initialization steps */
|
||||
async function init() {
|
||||
if (process.env.BALENARC_NO_SENTRY) {
|
||||
console.error(`WARN: disabling Sentry.io error reporting`);
|
||||
} else {
|
||||
await setupSentry();
|
||||
}
|
||||
checkNodeVersion();
|
||||
|
||||
const settings = new CliSettings();
|
||||
|
||||
// Proxy setup should be done early on, before loading balena-sdk
|
||||
await (await import('./utils/proxy')).setupGlobalHttpProxy(settings);
|
||||
|
||||
setupBalenaSdkSharedOptions(settings);
|
||||
|
||||
// check for CLI updates once a day
|
||||
(await import('./utils/update')).notify();
|
||||
}
|
||||
|
||||
/** Execute the oclif parser and the CLI 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) {
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
const { trackPromise } = await import('./hooks/prerun/track');
|
||||
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: import('./preparser').AppOptions = {},
|
||||
) {
|
||||
// DEBUG set to falsy for negative values else is truthy
|
||||
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
|
||||
process.env.DEBUG?.toLowerCase(),
|
||||
)
|
||||
? ''
|
||||
: '1';
|
||||
|
||||
// The 'pkgExec' special/internal command provides a Node.js interpreter
|
||||
// for use of the standalone zip package. See pkgExec function.
|
||||
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
|
||||
return pkgExec(cliArgs[3], cliArgs.slice(4));
|
||||
}
|
||||
|
||||
const { globalInit } = await import('./app-common');
|
||||
const { routeCliFramework } = await import('./preparser');
|
||||
|
||||
// globalInit() must be called very early on (before other imports) because
|
||||
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
|
||||
// shared options, and performs node version requirement checks.
|
||||
await globalInit();
|
||||
await routeCliFramework(cliArgs, options);
|
||||
|
||||
// Windows fix: reading from stdin prevents the process from exiting
|
||||
process.stdin.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the 'pkgExec' command, used as a way to provide a Node.js
|
||||
* interpreter for child_process.spawn()-like operations when the CLI is
|
||||
* executing as a standalone zip package (built-in Node interpreter) and
|
||||
* the system may not have a separate Node.js installation. A present use
|
||||
* case is a patched version of the 'windosu' package that requires a
|
||||
* Node.js interpreter to spawn a privileged child process.
|
||||
*
|
||||
* @param modFunc Path to a JS module that will be executed via require().
|
||||
* The modFunc argument may optionally contain a function name separated
|
||||
* by '::', for example '::main' in:
|
||||
* 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main'
|
||||
* in which case that function is executed in the require'd module.
|
||||
* @param args Optional arguments to passed through process.argv and as
|
||||
* arguments to the function specified via modFunc.
|
||||
*/
|
||||
async function pkgExec(modFunc: string, args: string[]) {
|
||||
const [modPath, funcName] = modFunc.split('::');
|
||||
let replacedModPath = modPath;
|
||||
const match = modPath
|
||||
.replace(/\\/g, '/')
|
||||
.match(/\/snapshot\/balena-cli\/(.+)/);
|
||||
if (match) {
|
||||
replacedModPath = `../${match[1]}`;
|
||||
}
|
||||
process.argv = [process.argv[0], process.argv[1], ...args];
|
||||
try {
|
||||
const mod: any = await import(replacedModPath);
|
||||
if (funcName) {
|
||||
await mod[funcName](...args);
|
||||
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
|
||||
normalizeEnvVars();
|
||||
|
||||
// The 'pkgExec' special/internal command provides a Node.js interpreter
|
||||
// for use of the standalone zip package. See pkgExec function.
|
||||
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
|
||||
return pkgExec(cliArgs[3], cliArgs.slice(4));
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const args = await preparseArgs(cliArgs);
|
||||
await oclifRun(args, options);
|
||||
} catch (err) {
|
||||
console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`);
|
||||
console.error(err);
|
||||
await (await import('./errors')).handleError(err);
|
||||
} finally {
|
||||
// Windows fix: reading from stdin prevents the process from exiting
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
|
@ -15,9 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { getBalenaSdk } from '../utils/lazy';
|
||||
import { awaitForToken } from './server';
|
||||
|
||||
export { shutdownServer } from './server';
|
||||
import { LoginServer } from './server';
|
||||
|
||||
/**
|
||||
* @module auth
|
||||
@ -43,28 +41,26 @@ export { shutdownServer } from './server';
|
||||
* console.log('I\'m logged in!')
|
||||
* console.log("My session token is: #{sessionToken}")
|
||||
*/
|
||||
export const login = async () => {
|
||||
export async function login({ host = '127.0.0.1', port = 0 }) {
|
||||
const utils = await import('./utils');
|
||||
|
||||
const options = {
|
||||
port: 8989,
|
||||
path: '/auth',
|
||||
};
|
||||
const loginServer = new LoginServer();
|
||||
const {
|
||||
host: actualHost,
|
||||
port: actualPort,
|
||||
urlPath,
|
||||
} = await loginServer.start({ host, port });
|
||||
|
||||
// Needs to be 127.0.0.1 not localhost, because the ip only is whitelisted
|
||||
// from mixed content warnings (as the target of a form in the result page)
|
||||
const callbackUrl = `http://127.0.0.1:${options.port}${options.path}`;
|
||||
const callbackUrl = `http://${actualHost}:${actualPort}${urlPath}`;
|
||||
const loginUrl = await utils.getDashboardLoginURL(callbackUrl);
|
||||
|
||||
console.info(`Opening web browser for URL:\n${loginUrl}`);
|
||||
// Leave a bit of time for the
|
||||
// server to get up and runing
|
||||
setTimeout(async () => {
|
||||
const open = await import('open');
|
||||
open(loginUrl, { wait: false });
|
||||
}, 1000);
|
||||
const open = await import('open');
|
||||
open(loginUrl, { wait: false });
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
const token = await awaitForToken(options);
|
||||
const token = await loginServer.awaitForToken();
|
||||
await balena.auth.loginWithToken(token);
|
||||
loginServer.shutdown();
|
||||
return token;
|
||||
};
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Balena CLI - Error</title>
|
||||
<title>balena CLI - Error</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Balena CLI - Success</title>
|
||||
<title>balena CLI - Success</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
|
||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as bodyParser from 'body-parser';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as express from 'express';
|
||||
import type { Socket } from 'net';
|
||||
import * as path from 'path';
|
||||
@ -22,68 +23,55 @@ import * as path from 'path';
|
||||
import * as utils from './utils';
|
||||
import { ExpectedError } from '../errors';
|
||||
|
||||
const serverSockets: Socket[] = [];
|
||||
export class LoginServer extends EventEmitter {
|
||||
protected expressApp: express.Express;
|
||||
protected server: import('net').Server;
|
||||
protected serverSockets: Socket[] = [];
|
||||
protected firstError: Error;
|
||||
protected token: string;
|
||||
|
||||
const createServer = ({ port }: { port: number }) => {
|
||||
const app = express();
|
||||
app.use(
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
}),
|
||||
);
|
||||
public readonly loginPath = '/auth';
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'pages'));
|
||||
/**
|
||||
* Start the HTTP server, listening on the given IP address and port number.
|
||||
* If the port number is 0, the OS will allocate a free port number.
|
||||
*/
|
||||
public async start({ host = '127.0.0.1', port = 0 } = {}): Promise<{
|
||||
host: string;
|
||||
port: number;
|
||||
urlPath: string;
|
||||
}> {
|
||||
this.once('error', (err: Error) => {
|
||||
this.firstError = err;
|
||||
});
|
||||
this.on('token', (token: string) => {
|
||||
this.token = token;
|
||||
});
|
||||
|
||||
const server = app.listen(port);
|
||||
server.on('connection', (socket) => serverSockets.push(socket));
|
||||
const app = (this.expressApp = express());
|
||||
app.use(
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return { app, server };
|
||||
};
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'pages'));
|
||||
|
||||
/**
|
||||
* By design (more like a bug, but they won't admit it), a Node.js `http.server`
|
||||
* instance prevents the process from exiting for up to 2 minutes (by default) if a
|
||||
* client keeps a HTTP connection open, and regardless of whether `server.close()`
|
||||
* was called: the `server.close(callback)` callback takes just as long to be called.
|
||||
* Setting `server.timeout` to some value like 3 seconds works, but then the CLI
|
||||
* process hangs for "only" 3 seconds (not good enough). Reducing the timeout to 1
|
||||
* second may cause authentication failure if the laptop or CI server are slow for
|
||||
* any reason. The only reliable way around it seems to be to explicitly unref the
|
||||
* sockets, so the event loop stops waiting for it. See:
|
||||
* https://github.com/nodejs/node/issues/2642
|
||||
* https://github.com/nodejs/node-v0.x-archive/issues/9066
|
||||
*/
|
||||
export function shutdownServer() {
|
||||
serverSockets.forEach((s) => s.unref());
|
||||
serverSockets.splice(0);
|
||||
}
|
||||
this.server = await new Promise<import('net').Server>((resolve, reject) => {
|
||||
const server = app.listen(port, host, (err: Error) => {
|
||||
if (err) {
|
||||
this.emit('error', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
server.on('connection', (socket) => this.serverSockets.push(socket));
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Await for token
|
||||
* @function
|
||||
* @protected
|
||||
*
|
||||
* @param {Object} options - options
|
||||
* @param {String} options.path - callback path
|
||||
* @param {Number} options.port - http port
|
||||
*
|
||||
* @example
|
||||
* server.awaitForToken
|
||||
* path: '/auth'
|
||||
* port: 9001
|
||||
* .then (token) ->
|
||||
* console.log(token)
|
||||
*/
|
||||
export const awaitForToken = (options: {
|
||||
path: string;
|
||||
port: number;
|
||||
}): Promise<string> => {
|
||||
const { app, server } = createServer({ port: options.port });
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
app.post(options.path, async (request, response) => {
|
||||
server.close(); // stop listening for new connections
|
||||
this.expressApp.post(this.loginPath, async (request, response) => {
|
||||
this.server.close(); // stop listening for new connections
|
||||
try {
|
||||
const token = request.body.token?.trim();
|
||||
if (!token) {
|
||||
@ -93,18 +81,68 @@ export const awaitForToken = (options: {
|
||||
if (!loggedIn) {
|
||||
throw new ExpectedError('Invalid token');
|
||||
}
|
||||
this.emit('token', token);
|
||||
response.status(200).render('success');
|
||||
resolve(token);
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
response.status(401).render('error');
|
||||
reject(new Error(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
app.use((_request, response) => {
|
||||
server.close(); // stop listening for new connections
|
||||
this.expressApp.use((_request, response) => {
|
||||
this.server.close(); // stop listening for new connections
|
||||
this.emit('error', new Error('Unknown path or verb'));
|
||||
response.status(404).send('Not found');
|
||||
reject(new Error('Unknown path or verb'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return this.getAddress();
|
||||
}
|
||||
|
||||
public getAddress(): { host: string; port: number; urlPath: string } {
|
||||
const info = this.server.address() as import('net').AddressInfo;
|
||||
return {
|
||||
host: info.address,
|
||||
port: info.port,
|
||||
urlPath: this.loginPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut the server down.
|
||||
* Call this method to avoid the process hanging in some situations.
|
||||
*/
|
||||
public shutdown() {
|
||||
// A Node.js `http.server` instance prevents the process from exiting for up to
|
||||
// 2 minutes (by default) if a client keeps a HTTP connection open, and regardless
|
||||
// of whether `server.close()` was called: the `server.close(callback)` callback
|
||||
// takes just as long to complete. Setting `server.timeout` to some value like
|
||||
// 3 seconds works, but then the CLI process hangs for "only" 3 seconds. Reducing
|
||||
// the timeout to 1 second may cause authentication failure if the laptop or CI
|
||||
// server are slow for any reason. The only reliable way around it seems to be to
|
||||
// explicitly unref the sockets, so the event loop stops waiting for it. See:
|
||||
// https://github.com/nodejs/node/issues/2642
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/9066
|
||||
//
|
||||
this.serverSockets.forEach((s) => s.unref());
|
||||
this.serverSockets.splice(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Await for the user to complete login through a web browser.
|
||||
* Resolve to the authentication token string.
|
||||
*
|
||||
* @return Promise that resolves to the authentication token string
|
||||
*/
|
||||
public async awaitForToken(): Promise<string> {
|
||||
if (this.firstError) {
|
||||
throw this.firstError;
|
||||
}
|
||||
if (this.token) {
|
||||
return this.token;
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.on('error', reject);
|
||||
this.on('token', resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as Bluebird from 'bluebird';
|
||||
import * as _ from 'lodash';
|
||||
import * as url from 'url';
|
||||
import { getBalenaSdk } from '../utils/lazy';
|
||||
@ -26,7 +25,7 @@ import { getBalenaSdk } from '../utils/lazy';
|
||||
*
|
||||
* @param {String} callbackUrl - callback url
|
||||
* @fulfil {String} - dashboard login url
|
||||
* @returns {Bluebird}
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
* utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
|
||||
@ -58,36 +57,33 @@ export const getDashboardLoginURL = (callbackUrl: string) => {
|
||||
*
|
||||
* @param {String} token - session token or api key
|
||||
* @fulfil {Boolean} - whether the login was successful or not
|
||||
* @returns {Bluebird}
|
||||
* @returns {Promise}
|
||||
*
|
||||
* utils.loginIfTokenValid('...').then (loggedIn) ->
|
||||
* if loggedIn
|
||||
* console.log('Token is valid!')
|
||||
*/
|
||||
export const loginIfTokenValid = (token: string) => {
|
||||
export const loginIfTokenValid = async (token: string): Promise<boolean> => {
|
||||
if (_.isEmpty(token?.trim())) {
|
||||
return Bluebird.resolve(false);
|
||||
return false;
|
||||
}
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
return balena.auth
|
||||
.getToken()
|
||||
.catchReturn(undefined)
|
||||
.then((currentToken) =>
|
||||
balena.auth
|
||||
.loginWithToken(token)
|
||||
.return(token)
|
||||
.then(balena.auth.isLoggedIn)
|
||||
.tap((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
let currentToken;
|
||||
try {
|
||||
currentToken = await balena.auth.getToken();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (currentToken != null) {
|
||||
return balena.auth.loginWithToken(currentToken);
|
||||
} else {
|
||||
return balena.auth.logout();
|
||||
}
|
||||
}),
|
||||
);
|
||||
await balena.auth.loginWithToken(token);
|
||||
const isLoggedIn = await balena.auth.isLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
if (currentToken != null) {
|
||||
await balena.auth.loginWithToken(currentToken);
|
||||
} else {
|
||||
await balena.auth.logout();
|
||||
}
|
||||
}
|
||||
return isLoggedIn;
|
||||
};
|
||||
|
@ -78,11 +78,25 @@ export default abstract class BalenaCommand extends Command {
|
||||
* Note, currently public to allow use outside of derived commands
|
||||
* (as some command implementations require this. Can be made protected
|
||||
* if this changes).
|
||||
*
|
||||
* @throws {NotLoggedInError}
|
||||
*/
|
||||
public static async checkLoggedIn() {
|
||||
await (await import('./utils/patterns')).checkLoggedIn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw NotLoggedInError if not logged in when condition true.
|
||||
*
|
||||
* @param {boolean} doCheck - will check if true.
|
||||
* @throws {NotLoggedInError}
|
||||
*/
|
||||
public static async checkLoggedInIf(doCheck: boolean) {
|
||||
if (doCheck) {
|
||||
await this.checkLoggedIn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read stdin contents and make available to command.
|
||||
*
|
||||
@ -93,6 +107,13 @@ export default abstract class BalenaCommand extends Command {
|
||||
this.stdin = await (await import('get-stdin'))();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a logger instance.
|
||||
*/
|
||||
protected static async getLogger() {
|
||||
return (await import('./utils/logger')).getLogger();
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
const ctr = this.constructor as typeof BalenaCommand;
|
||||
|
||||
|
@ -86,6 +86,7 @@ export default class AppCreateCmd extends Command {
|
||||
application = await balena.models.application.create({
|
||||
name: params.name,
|
||||
deviceType,
|
||||
organization: (await balena.auth.whoami())!,
|
||||
});
|
||||
} catch (err) {
|
||||
// BalenaRequestError: Request error: Unique key constraint violated
|
||||
@ -97,7 +98,7 @@ export default class AppCreateCmd extends Command {
|
||||
throw err;
|
||||
}
|
||||
console.info(
|
||||
`Application created: ${application.slug} (${application.device_type}, id ${application.id})`,
|
||||
`Application created: ${application.slug} (${deviceType}, id ${application.id})`,
|
||||
);
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ import { flags } from '@oclif/command';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
|
||||
import { tryAsInteger } from '../../utils/validation';
|
||||
import type { Release } from 'balena-sdk';
|
||||
|
||||
interface FlagsDef {
|
||||
help: void;
|
||||
@ -57,10 +57,21 @@ export default class AppCmd extends Command {
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppCmd);
|
||||
|
||||
const application = await getBalenaSdk().models.application.get(
|
||||
tryAsInteger(params.name),
|
||||
);
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
|
||||
const application = (await getApplication(getBalenaSdk(), params.name, {
|
||||
$expand: {
|
||||
is_for__device_type: { $select: 'slug' },
|
||||
should_be_running__release: { $select: 'commit' },
|
||||
},
|
||||
})) as ApplicationWithDeviceType & {
|
||||
should_be_running__release: [Release?];
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
application.device_type = application.is_for__device_type[0].slug;
|
||||
// @ts-expect-error
|
||||
application.commit = application.should_be_running__release[0]?.commit;
|
||||
console.log(
|
||||
getVisuals().table.vertical(application, [
|
||||
`$${application.app_name}$`,
|
82
lib/commands/app/purge.ts
Normal file
82
lib/commands/app/purge.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @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 {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default class AppRestartCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Purge data from an application.
|
||||
|
||||
Purge data from all devices belonging to an application.
|
||||
This will clear the application's /data directory.
|
||||
`;
|
||||
public static examples = ['$ balena app purge MyApp'];
|
||||
|
||||
public static args = [
|
||||
{
|
||||
name: 'name',
|
||||
description: 'application name or numeric ID',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'app purge <name>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
// balena.models.application.purge only accepts a numeric id
|
||||
// so we must first fetch the app to get it's id, if we have been given a name
|
||||
let nameOrId = tryAsInteger(params.name);
|
||||
|
||||
if (typeof nameOrId === 'string') {
|
||||
const app = await balena.models.application.get(nameOrId);
|
||||
nameOrId = app.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await balena.models.application.purge(nameOrId);
|
||||
} catch (e) {
|
||||
if (e.message.toLowerCase().includes('no online device(s) found')) {
|
||||
// application.purge throws an error if no devices are online
|
||||
// ignore in this case.
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
136
lib/commands/app/rename.ts
Normal file
136
lib/commands/app/rename.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @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 type { IArg } from '@oclif/parser/lib/args';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
|
||||
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
|
||||
|
||||
interface FlagsDef {
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
name: string;
|
||||
newName?: string;
|
||||
}
|
||||
|
||||
export default class AppRenameCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Rename an application.
|
||||
|
||||
Rename an application.
|
||||
|
||||
Note, if the \`newName\` parameter is omitted, it will be
|
||||
prompted for interactively.
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena app rename OldName',
|
||||
'$ balena app rename OldName NewName',
|
||||
];
|
||||
|
||||
public static args: Array<IArg<any>> = [
|
||||
{
|
||||
name: 'name',
|
||||
description: 'application name or numeric ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'newName',
|
||||
description: 'the new name for the application',
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'app rename <name> [newName]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
|
||||
|
||||
const { ExpectedError, instanceOf } = await import('../../errors');
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
// Get app
|
||||
let app;
|
||||
try {
|
||||
app = await getApplication(balena, params.name, {
|
||||
$expand: {
|
||||
application_type: {
|
||||
$select: ['is_legacy'],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const { BalenaApplicationNotFound } = await import('balena-errors');
|
||||
if (instanceOf(e, BalenaApplicationNotFound)) {
|
||||
throw new ExpectedError(`Application ${params.name} not found.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Check app supports renaming
|
||||
const appType = (app.application_type as ApplicationType[])?.[0];
|
||||
if (appType.is_legacy) {
|
||||
throw new ExpectedError(
|
||||
`Application ${params.name} is of 'legacy' type, and cannot be renamed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { validateApplicationName } = await import('../../utils/validation');
|
||||
const newName =
|
||||
params.newName ||
|
||||
(await getCliForm().ask({
|
||||
message: 'Please enter the new name for this application:',
|
||||
type: 'input',
|
||||
validate: validateApplicationName,
|
||||
})) ||
|
||||
'';
|
||||
|
||||
try {
|
||||
await this.renameApplication(balena, app.id, newName);
|
||||
} catch (e) {
|
||||
// BalenaRequestError: Request error: Unique key constraint violated
|
||||
if ((e.message || '').toLowerCase().includes('unique')) {
|
||||
throw new ExpectedError(
|
||||
`Error: application ${params.name} already exists.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`Application ${params.name} renamed to ${newName}`);
|
||||
}
|
||||
|
||||
async renameApplication(balena: BalenaSDK, id: number, newName: string) {
|
||||
return balena.pine.patch<Application>({
|
||||
resource: 'application',
|
||||
id,
|
||||
body: {
|
||||
app_name: newName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -33,7 +33,7 @@ export default class AppRestartCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Restart an application.
|
||||
|
||||
Restart all devices that belongs to a certain application.
|
||||
Restart all devices belonging to an application.
|
||||
`;
|
||||
public static examples = ['$ balena app restart MyApp'];
|
||||
|
@ -16,13 +16,12 @@
|
||||
*/
|
||||
|
||||
import { flags } from '@oclif/command';
|
||||
import type { Application } from 'balena-sdk';
|
||||
import Command from '../command';
|
||||
import * as cf from '../utils/common-flags';
|
||||
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
|
||||
import { isV12 } from '../utils/version';
|
||||
|
||||
interface ExtendedApplication extends Application {
|
||||
interface ExtendedApplication extends ApplicationWithDeviceType {
|
||||
device_count?: number;
|
||||
online_devices?: number;
|
||||
}
|
||||
@ -64,12 +63,13 @@ export default class AppsCmd extends Command {
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
// Get applications
|
||||
const applications: ExtendedApplication[] = await balena.models.application.getAll(
|
||||
{
|
||||
$select: ['id', 'app_name', 'slug', 'device_type'],
|
||||
$expand: { owns__device: { $select: 'is_online' } },
|
||||
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
|
||||
@ -78,6 +78,8 @@ export default class AppsCmd extends Command {
|
||||
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
|
258
lib/commands/build.ts
Normal file
258
lib/commands/build.ts
Normal file
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @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 { getBalenaSdk } from '../utils/lazy';
|
||||
import * as compose from '../utils/compose';
|
||||
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
|
||||
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';
|
||||
|
||||
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
|
||||
arch?: string;
|
||||
deviceType?: string;
|
||||
application?: string;
|
||||
source?: string; // Not part of command profile - source param copied here.
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export default class BuildCmd extends Command {
|
||||
public static description = `\
|
||||
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.)
|
||||
|
||||
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.
|
||||
|
||||
${registrySecretsHelp}
|
||||
|
||||
${dockerignoreHelp}
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena build --application myApp',
|
||||
'$ balena build ./source/ --application myApp',
|
||||
'$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated',
|
||||
'$ 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 = [
|
||||
{
|
||||
name: 'source',
|
||||
description: 'path of project source directory',
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'build [source]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
arch: flags.string({
|
||||
description: 'the architecture to build for',
|
||||
char: 'A',
|
||||
}),
|
||||
deviceType: flags.string({
|
||||
description: 'the type of device this build is for',
|
||||
char: 'd',
|
||||
}),
|
||||
application: flags.string({
|
||||
description: 'name of the target balena application this build is for',
|
||||
char: 'a',
|
||||
}),
|
||||
...composeCliFlags,
|
||||
...dockerCliFlags,
|
||||
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
|
||||
// Revisit this in future release.
|
||||
help: flags.help({}),
|
||||
};
|
||||
|
||||
public static primary = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||
BuildCmd,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
const logger = await Command.getLogger();
|
||||
logger.logDebug('Parsing input...');
|
||||
|
||||
// `build` accepts `source` as a parameter, but compose expects it as an option
|
||||
options.source = params.source;
|
||||
delete params.source;
|
||||
|
||||
await this.validateOptions(options, sdk);
|
||||
|
||||
const app = await this.getAppAndResolveArch(options);
|
||||
|
||||
const { docker, buildOpts, composeOpts } = await this.prepareBuild(options);
|
||||
|
||||
try {
|
||||
await this.buildProject(docker, logger, composeOpts, {
|
||||
app,
|
||||
arch: options.arch!,
|
||||
deviceType: options.deviceType!,
|
||||
buildEmulated: options.emulated,
|
||||
buildOpts,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.logError('Build failed.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.outputDeferredMessages();
|
||||
logger.logSuccess('Build succeeded!');
|
||||
}
|
||||
|
||||
protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) {
|
||||
// Validate option combinations
|
||||
if (
|
||||
(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 an application or an arch/deviceType pair to build for',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate project directory
|
||||
const { validateProjectDirectory } = await import('../utils/compose_ts');
|
||||
const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
|
||||
sdk,
|
||||
{
|
||||
dockerfilePath: opts.dockerfile,
|
||||
noParentCheck: opts['noparent-check'] || false,
|
||||
projectPath: opts.source || '.',
|
||||
registrySecretsPath: opts['registry-secrets'],
|
||||
},
|
||||
);
|
||||
|
||||
opts.dockerfile = dockerfilePath;
|
||||
opts['registry-secrets'] = registrySecrets;
|
||||
}
|
||||
|
||||
protected async getAppAndResolveArch(opts: FlagsDef) {
|
||||
if (opts.application) {
|
||||
const { getAppWithArch } = await import('../utils/helpers');
|
||||
const app = await getAppWithArch(opts.application);
|
||||
opts.arch = app.arch;
|
||||
opts.deviceType = app.is_for__device_type[0].slug;
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
protected async prepareBuild(options: FlagsDef) {
|
||||
const { getDocker, generateBuildOpts } = await import('../utils/docker');
|
||||
const [docker, buildOpts, composeOpts] = await Promise.all([
|
||||
getDocker(options),
|
||||
generateBuildOpts(options),
|
||||
compose.generateOpts(options),
|
||||
]);
|
||||
return {
|
||||
docker,
|
||||
buildOpts,
|
||||
composeOpts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opts must be an object with the following keys:
|
||||
* app: the app this build is for (optional)
|
||||
* arch: the architecture to build for
|
||||
* deviceType: the device type to build for
|
||||
* buildEmulated
|
||||
* buildOpts: arguments to forward to docker build command
|
||||
*
|
||||
* @param {DockerToolbelt} docker
|
||||
* @param {Logger} logger
|
||||
* @param {ComposeOpts} composeOpts
|
||||
* @param opts
|
||||
*/
|
||||
protected async buildProject(
|
||||
docker: import('docker-toolbelt'),
|
||||
logger: import('../utils/logger'),
|
||||
composeOpts: ComposeOpts,
|
||||
opts: {
|
||||
app?: Application;
|
||||
arch: string;
|
||||
deviceType: string;
|
||||
buildEmulated: boolean;
|
||||
buildOpts: BuildOpts;
|
||||
},
|
||||
) {
|
||||
const { loadProject } = await import('../utils/compose_ts');
|
||||
|
||||
const project = await loadProject(logger, composeOpts);
|
||||
|
||||
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
|
||||
if (
|
||||
appType != null &&
|
||||
project.descriptors.length > 1 &&
|
||||
!appType.supports_multicontainer
|
||||
) {
|
||||
logger.logWarn(
|
||||
'Target application does not support multiple containers.\n' +
|
||||
'Continuing with build, but you will not be able to deploy.',
|
||||
);
|
||||
}
|
||||
|
||||
await buildProject({
|
||||
docker,
|
||||
logger,
|
||||
projectPath: project.path,
|
||||
projectName: project.name,
|
||||
composition: project.composition,
|
||||
arch: opts.arch,
|
||||
deviceType: opts.deviceType,
|
||||
emulated: opts.buildEmulated,
|
||||
buildOpts: opts.buildOpts,
|
||||
inlineLogs: composeOpts.inlineLogs,
|
||||
convertEol: composeOpts.convertEol,
|
||||
dockerfilePath: composeOpts.dockerfilePath,
|
||||
nogitignore: composeOpts.nogitignore,
|
||||
multiDockerignore: composeOpts.multiDockerignore,
|
||||
});
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ import { flags } from '@oclif/command';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, getCliForm, stripIndent } from '../../utils/lazy';
|
||||
import type { Device, Application, PineDeferred } from 'balena-sdk';
|
||||
import type { PineDeferred } from 'balena-sdk';
|
||||
|
||||
interface FlagsDef {
|
||||
version: string; // OS version
|
||||
@ -126,22 +126,43 @@ export default class ConfigGenerateCmd extends Command {
|
||||
public async run() {
|
||||
const { flags: options } = this.parse<FlagsDef, {}>(ConfigGenerateCmd);
|
||||
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
await this.validateOptions(options);
|
||||
|
||||
// Get device | application
|
||||
let resource;
|
||||
let resourceDeviceType: string;
|
||||
let application: ApplicationWithDeviceType | null = null;
|
||||
let device:
|
||||
| (DeviceWithDeviceType & { belongs_to__application: PineDeferred })
|
||||
| null = null;
|
||||
if (options.device != null) {
|
||||
const { tryAsInteger } = await import('../../utils/validation');
|
||||
resource = (await balena.models.device.get(
|
||||
const rawDevice = await balena.models.device.get(
|
||||
tryAsInteger(options.device),
|
||||
)) as Device & { belongs_to__application: PineDeferred };
|
||||
{ $expand: { is_of__device_type: { $select: 'slug' } } },
|
||||
);
|
||||
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 application.
|
||||
Try with a different device, or use '--application' instead of '--device'.`);
|
||||
}
|
||||
device = rawDevice as DeviceWithDeviceType & {
|
||||
belongs_to__application: PineDeferred;
|
||||
};
|
||||
resourceDeviceType = device.is_of__device_type[0].slug;
|
||||
} else {
|
||||
resource = await balena.models.application.get(options.application!);
|
||||
application = (await getApplication(balena, options.application!, {
|
||||
$expand: {
|
||||
is_for__device_type: { $select: 'slug' },
|
||||
},
|
||||
})) as ApplicationWithDeviceType;
|
||||
resourceDeviceType = application.is_for__device_type[0].slug;
|
||||
}
|
||||
|
||||
const deviceType = options.deviceType || resource.device_type;
|
||||
const deviceType = options.deviceType || resourceDeviceType;
|
||||
|
||||
const deviceManifest = await balena.models.device.getManifestBySlug(
|
||||
deviceType,
|
||||
@ -150,7 +171,7 @@ export default class ConfigGenerateCmd extends Command {
|
||||
// Check compatibility if application and deviceType provided
|
||||
if (options.application && options.deviceType) {
|
||||
const appDeviceManifest = await balena.models.device.getManifestBySlug(
|
||||
resource.device_type,
|
||||
resourceDeviceType,
|
||||
);
|
||||
|
||||
const helpers = await import('../../utils/helpers');
|
||||
@ -177,18 +198,15 @@ export default class ConfigGenerateCmd extends Command {
|
||||
);
|
||||
|
||||
let config;
|
||||
if ('uuid' in resource && resource.uuid != null) {
|
||||
if (device) {
|
||||
config = await generateDeviceConfig(
|
||||
resource,
|
||||
device,
|
||||
options.deviceApiKey || options['generate-device-api-key'] || undefined,
|
||||
answers,
|
||||
);
|
||||
} else {
|
||||
} else if (application) {
|
||||
answers.deviceType = deviceType;
|
||||
config = await generateApplicationConfig(
|
||||
resource as Application,
|
||||
answers,
|
||||
);
|
||||
config = await generateApplicationConfig(application, answers);
|
||||
}
|
||||
|
||||
// Output
|
352
lib/commands/deploy.ts
Normal file
352
lib/commands/deploy.ts
Normal file
@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @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 type { ImageDescriptor } from 'resin-compose-parse';
|
||||
|
||||
import Command from '../command';
|
||||
import { ExpectedError } from '../errors';
|
||||
import { getBalenaSdk, getChalk } from '../utils/lazy';
|
||||
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
|
||||
import * as compose from '../utils/compose';
|
||||
import type {
|
||||
BuiltImage,
|
||||
ComposeCliFlags,
|
||||
ComposeOpts,
|
||||
} from '../utils/compose-types';
|
||||
import type { DockerCliFlags } from '../utils/docker';
|
||||
import {
|
||||
buildProject,
|
||||
composeCliFlags,
|
||||
isBuildConfig,
|
||||
} from '../utils/compose_ts';
|
||||
import { dockerCliFlags } from '../utils/docker';
|
||||
import type { Application, ApplicationType, DeviceType } from 'balena-sdk';
|
||||
|
||||
interface ApplicationWithArch extends Application {
|
||||
arch: string;
|
||||
}
|
||||
|
||||
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
|
||||
source?: string;
|
||||
build: boolean;
|
||||
nologupload: boolean;
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
appName: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export default class DeployCmd extends Command {
|
||||
public static description = `\
|
||||
Deploy a single image or a multicontainer project to a balena application.
|
||||
|
||||
Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
|
||||
|
||||
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.)
|
||||
|
||||
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. 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 an app on which you're a collaborator, use
|
||||
\`balena deploy <appOwnerUsername>/<appName>\`.
|
||||
|
||||
${registrySecretsHelp}
|
||||
|
||||
${dockerignoreHelp}
|
||||
`;
|
||||
|
||||
public static examples = [
|
||||
'$ balena deploy myApp',
|
||||
'$ balena deploy myApp --build --source myBuildDir/',
|
||||
'$ balena deploy myApp myApp/myImage',
|
||||
];
|
||||
|
||||
public static args = [
|
||||
{
|
||||
name: 'appName',
|
||||
description: 'the name of the application to deploy to',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
description: 'the image to deploy',
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'deploy <appName> [image]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
source: flags.string({
|
||||
description:
|
||||
'specify an alternate source directory; default is the working directory',
|
||||
char: 's',
|
||||
}),
|
||||
build: flags.boolean({
|
||||
description: 'force a rebuild before deploy',
|
||||
char: 'b',
|
||||
}),
|
||||
nologupload: flags.boolean({
|
||||
description:
|
||||
"don't upload build logs to the dashboard with image (if building)",
|
||||
}),
|
||||
...composeCliFlags,
|
||||
...dockerCliFlags,
|
||||
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
|
||||
// Revisit this in future release.
|
||||
help: flags.help({}),
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public static primary = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||
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 { appName, image } = params;
|
||||
|
||||
if (image != null && options.build) {
|
||||
throw new ExpectedError(
|
||||
'Build option is not applicable when specifying an image',
|
||||
);
|
||||
}
|
||||
|
||||
const sdk = getBalenaSdk();
|
||||
const { getRegistrySecrets, validateProjectDirectory } = await import(
|
||||
'../utils/compose_ts'
|
||||
);
|
||||
|
||||
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'],
|
||||
});
|
||||
options.dockerfile = dockerfilePath;
|
||||
options['registry-secrets'] = registrySecrets;
|
||||
}
|
||||
|
||||
const helpers = await import('../utils/helpers');
|
||||
const app = await helpers.getAppWithArch(appName);
|
||||
|
||||
const dockerUtils = await import('../utils/docker');
|
||||
const [docker, buildOpts, composeOpts] = await Promise.all([
|
||||
dockerUtils.getDocker(options),
|
||||
dockerUtils.generateBuildOpts(options),
|
||||
compose.generateOpts(options),
|
||||
]);
|
||||
|
||||
await this.deployProject(docker, logger, composeOpts, {
|
||||
app,
|
||||
appName, // may be prefixed by 'owner/', unlike app.app_name
|
||||
image,
|
||||
shouldPerformBuild: !!options.build,
|
||||
shouldUploadLogs: !options.nologupload,
|
||||
buildEmulated: !!options.emulated,
|
||||
buildOpts,
|
||||
});
|
||||
}
|
||||
|
||||
async deployProject(
|
||||
docker: import('docker-toolbelt'),
|
||||
logger: import('../utils/logger'),
|
||||
composeOpts: ComposeOpts,
|
||||
opts: {
|
||||
app: ApplicationWithArch; // the application instance to deploy to
|
||||
appName: string;
|
||||
image?: string;
|
||||
dockerfilePath?: string; // alternative Dockerfile
|
||||
shouldPerformBuild: boolean;
|
||||
shouldUploadLogs: boolean;
|
||||
buildEmulated: boolean;
|
||||
buildOpts: any; // arguments to forward to docker build command
|
||||
},
|
||||
) {
|
||||
const _ = await import('lodash');
|
||||
const doodles = await import('resin-doodles');
|
||||
const sdk = getBalenaSdk();
|
||||
const { deployProject: $deployProject, loadProject } = await import(
|
||||
'../utils/compose_ts'
|
||||
);
|
||||
|
||||
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
|
||||
|
||||
try {
|
||||
const project = await loadProject(logger, composeOpts, opts.image);
|
||||
if (project.descriptors.length > 1 && !appType?.supports_multicontainer) {
|
||||
throw new ExpectedError(
|
||||
'Target application does not support multiple containers. Aborting!',
|
||||
);
|
||||
}
|
||||
|
||||
// find which services use images that already exist locally
|
||||
let servicesToSkip: string[] = await Promise.all(
|
||||
project.descriptors.map(async function (d: ImageDescriptor) {
|
||||
// unconditionally build (or pull) if explicitly requested
|
||||
if (opts.shouldPerformBuild) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
await docker
|
||||
.getImage((isBuildConfig(d.image) ? d.image.tag : d.image) || '')
|
||||
.inspect();
|
||||
|
||||
return d.serviceName;
|
||||
} catch {
|
||||
// Ignore
|
||||
return '';
|
||||
}
|
||||
}),
|
||||
);
|
||||
servicesToSkip = servicesToSkip.filter((d) => !!d);
|
||||
|
||||
// multibuild takes in a composition and always attempts to
|
||||
// build or pull all services. we workaround that here by
|
||||
// passing a modified composition.
|
||||
const compositionToBuild = _.cloneDeep(project.composition);
|
||||
compositionToBuild.services = _.omit(
|
||||
compositionToBuild.services,
|
||||
servicesToSkip,
|
||||
);
|
||||
let builtImagesByService: Dictionary<BuiltImage> = {};
|
||||
if (_.size(compositionToBuild.services) === 0) {
|
||||
logger.logInfo(
|
||||
'Everything is up to date (use --build to force a rebuild)',
|
||||
);
|
||||
} else {
|
||||
const builtImages = await buildProject({
|
||||
docker,
|
||||
logger,
|
||||
projectPath: project.path,
|
||||
projectName: project.name,
|
||||
composition: compositionToBuild,
|
||||
arch: opts.app.arch,
|
||||
deviceType: (opts.app?.is_for__device_type as DeviceType[])?.[0].slug,
|
||||
emulated: opts.buildEmulated,
|
||||
buildOpts: opts.buildOpts,
|
||||
inlineLogs: composeOpts.inlineLogs,
|
||||
convertEol: composeOpts.convertEol,
|
||||
dockerfilePath: composeOpts.dockerfilePath,
|
||||
nogitignore: composeOpts.nogitignore,
|
||||
multiDockerignore: composeOpts.multiDockerignore,
|
||||
});
|
||||
builtImagesByService = _.keyBy(builtImages, 'serviceName');
|
||||
}
|
||||
const images: BuiltImage[] = project.descriptors.map(
|
||||
(d) =>
|
||||
builtImagesByService[d.serviceName] ?? {
|
||||
serviceName: d.serviceName,
|
||||
name: (isBuildConfig(d.image) ? d.image.tag : d.image) || '',
|
||||
logs: 'Build skipped; image for service already exists.',
|
||||
props: {},
|
||||
},
|
||||
);
|
||||
|
||||
let release;
|
||||
if (appType?.is_legacy) {
|
||||
const { deployLegacy } = require('../utils/deploy-legacy');
|
||||
|
||||
const msg = getChalk().yellow(
|
||||
'Target application requires legacy deploy method.',
|
||||
);
|
||||
logger.logWarn(msg);
|
||||
|
||||
const [token, username, url, options] = await Promise.all([
|
||||
sdk.auth.getToken(),
|
||||
sdk.auth.whoami(),
|
||||
sdk.settings.get('balenaUrl'),
|
||||
{
|
||||
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
|
||||
appName: opts.appName,
|
||||
imageName: images[0].name,
|
||||
buildLogs: images[0].logs,
|
||||
shouldUploadLogs: opts.shouldUploadLogs,
|
||||
},
|
||||
]);
|
||||
const releaseId = await deployLegacy(
|
||||
docker,
|
||||
logger,
|
||||
token,
|
||||
username,
|
||||
url,
|
||||
options,
|
||||
);
|
||||
|
||||
release = await sdk.models.release.get(releaseId, {
|
||||
$select: ['commit'],
|
||||
});
|
||||
} else {
|
||||
const [userId, auth, apiEndpoint] = await Promise.all([
|
||||
sdk.auth.getUserId(),
|
||||
sdk.auth.getToken(),
|
||||
sdk.settings.get('apiUrl'),
|
||||
]);
|
||||
release = await $deployProject(
|
||||
docker,
|
||||
logger,
|
||||
project.composition,
|
||||
images,
|
||||
opts.app.id,
|
||||
userId,
|
||||
`Bearer ${auth}`,
|
||||
apiEndpoint,
|
||||
!opts.shouldUploadLogs,
|
||||
);
|
||||
}
|
||||
|
||||
logger.outputDeferredMessages();
|
||||
logger.logSuccess('Deploy succeeded!');
|
||||
logger.logSuccess(`Release: ${release.commit}`);
|
||||
console.log();
|
||||
console.log(doodles.getDoodle()); // Show charlie
|
||||
console.log();
|
||||
} catch (err) {
|
||||
logger.logError('Deploy failed');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
@ -22,13 +22,23 @@ 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, Device } from 'balena-sdk';
|
||||
import type { Application, Release } from 'balena-sdk';
|
||||
|
||||
interface ExtendedDevice extends Device {
|
||||
interface ExtendedDevice extends DeviceWithDeviceType {
|
||||
dashboard_url?: string;
|
||||
application_name?: string;
|
||||
device_type?: string;
|
||||
commit?: string;
|
||||
last_seen?: string;
|
||||
memory_usage_mb: number | null;
|
||||
memory_total_mb: number | null;
|
||||
memory_usage_percent?: number;
|
||||
storage_usage_mb: number | null;
|
||||
storage_total_mb: number | null;
|
||||
storage_usage_percent?: number;
|
||||
cpu_temp_c: number | null;
|
||||
cpu_usage_percent: number | null;
|
||||
undervoltage_detected?: boolean;
|
||||
}
|
||||
|
||||
interface FlagsDef {
|
||||
@ -70,25 +80,32 @@ export default class DeviceCmd extends Command {
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
const device: ExtendedDevice = await balena.models.device.get(params.uuid, {
|
||||
const device = (await balena.models.device.get(params.uuid, {
|
||||
$select: [
|
||||
'device_name',
|
||||
'id',
|
||||
'device_type',
|
||||
'overall_status',
|
||||
'is_online',
|
||||
'ip_address',
|
||||
'mac_address',
|
||||
'last_connectivity_event',
|
||||
'uuid',
|
||||
'is_on__commit',
|
||||
'supervisor_version',
|
||||
'is_web_accessible',
|
||||
'note',
|
||||
'os_version',
|
||||
'memory_usage',
|
||||
'memory_total',
|
||||
'storage_block_device',
|
||||
'storage_usage',
|
||||
'storage_total',
|
||||
'cpu_usage',
|
||||
'cpu_temp',
|
||||
'cpu_id',
|
||||
'is_undervolted',
|
||||
],
|
||||
...expandForAppName,
|
||||
});
|
||||
})) as ExtendedDevice;
|
||||
device.status = device.overall_status;
|
||||
|
||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
|
||||
@ -98,8 +115,50 @@ export default class DeviceCmd extends Command {
|
||||
? belongsToApplication[0].app_name
|
||||
: 'N/a';
|
||||
|
||||
device.commit = device.is_on__commit;
|
||||
device.last_seen = device.last_connectivity_event;
|
||||
device.device_type = device.is_of__device_type[0].slug;
|
||||
|
||||
const isRunningRelease = device.is_running__release as Release[];
|
||||
device.commit = isRunningRelease?.[0] ? isRunningRelease[0].commit : 'N/a';
|
||||
|
||||
device.last_seen = device.last_connectivity_event ?? undefined;
|
||||
|
||||
// Memory/Storage are really MiB
|
||||
// Consider changing headings to MiB once we can do lowercase
|
||||
|
||||
device.memory_usage_mb = device.memory_usage;
|
||||
device.memory_total_mb = device.memory_total;
|
||||
|
||||
device.storage_usage_mb = device.storage_usage;
|
||||
device.storage_total_mb = device.storage_total;
|
||||
|
||||
device.cpu_temp_c = device.cpu_temp;
|
||||
device.cpu_usage_percent = device.cpu_usage;
|
||||
|
||||
// Only show undervoltage status if true
|
||||
// API sends false even for devices which are not detecting this.
|
||||
if (device.is_undervolted) {
|
||||
device.undervoltage_detected = device.is_undervolted;
|
||||
}
|
||||
|
||||
if (
|
||||
device.memory_usage != null &&
|
||||
device.memory_total != null &&
|
||||
device.memory_total !== 0
|
||||
) {
|
||||
device.memory_usage_percent = Math.round(
|
||||
(device.memory_usage / device.memory_total) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
device.storage_usage != null &&
|
||||
device.storage_total != null &&
|
||||
device.storage_total !== 0
|
||||
) {
|
||||
device.storage_usage_percent = Math.round(
|
||||
(device.storage_usage / device.storage_total) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
getVisuals().table.vertical(device, [
|
||||
@ -119,6 +178,17 @@ export default class DeviceCmd extends Command {
|
||||
'note',
|
||||
'os_version',
|
||||
'dashboard_url',
|
||||
'cpu_usage_percent',
|
||||
'cpu_temp_c',
|
||||
'cpu_id',
|
||||
'memory_usage_mb',
|
||||
'memory_total_mb',
|
||||
'memory_usage_percent',
|
||||
'storage_block_device',
|
||||
'storage_usage_mb',
|
||||
'storage_total_mb',
|
||||
'storage_usage_percent',
|
||||
'undervoltage_detected',
|
||||
]),
|
||||
);
|
||||
}
|
@ -79,26 +79,34 @@ export default class DeviceInitCmd extends Command {
|
||||
const { flags: options } = this.parse<FlagsDef, {}>(DeviceInitCmd);
|
||||
|
||||
// Imports
|
||||
const { promisify } = await import('bluebird');
|
||||
const { promisify } = await import('util');
|
||||
const rimraf = promisify(await import('rimraf'));
|
||||
const tmp = await import('tmp');
|
||||
const tmpNameAsync = promisify(tmp.tmpName);
|
||||
tmp.setGracefulCleanup();
|
||||
const balena = getBalenaSdk();
|
||||
const { downloadOSImage } = await import('../../utils/cloud');
|
||||
const Logger = await import('../../utils/logger');
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
|
||||
const logger = Logger.getLogger();
|
||||
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 balena.models.application.get(
|
||||
const application = (await getApplication(
|
||||
balena,
|
||||
options['application'] ||
|
||||
(await (await import('../../utils/patterns')).selectApplication()),
|
||||
);
|
||||
{
|
||||
$expand: {
|
||||
is_for__device_type: {
|
||||
$select: 'slug',
|
||||
},
|
||||
},
|
||||
},
|
||||
)) as ApplicationWithDeviceType;
|
||||
|
||||
// Register new device
|
||||
const deviceUuid = balena.models.device.generateUniqueKey();
|
||||
@ -111,13 +119,14 @@ export default class DeviceInitCmd extends Command {
|
||||
try {
|
||||
logger.logDebug(`Downloading OS image...`);
|
||||
const osVersion = options['os-version'] || 'default';
|
||||
await downloadOSImage(application.device_type, tmpPath, osVersion);
|
||||
const deviceType = application.is_for__device_type[0].slug;
|
||||
await downloadOSImage(deviceType, tmpPath, osVersion);
|
||||
|
||||
logger.logDebug(`Configuring OS image...`);
|
||||
await this.configureOsImage(tmpPath, device.uuid, options);
|
||||
|
||||
logger.logDebug(`Writing OS image...`);
|
||||
await this.writeOsImage(tmpPath, application.device_type, options);
|
||||
await this.writeOsImage(tmpPath, deviceType, options);
|
||||
} catch (e) {
|
||||
// Remove device in failed cases
|
||||
try {
|
@ -17,7 +17,7 @@
|
||||
|
||||
import { flags } from '@oclif/command';
|
||||
import type { IArg } from '@oclif/parser/lib/args';
|
||||
import type { Application, Device } from 'balena-sdk';
|
||||
import type { Application, BalenaSDK } from 'balena-sdk';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { expandForAppName } from '../../utils/helpers';
|
||||
@ -25,7 +25,7 @@ import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||
import { tryAsInteger } from '../../utils/validation';
|
||||
import { ExpectedError } from '../../errors';
|
||||
|
||||
interface ExtendedDevice extends Device {
|
||||
interface ExtendedDevice extends DeviceWithDeviceType {
|
||||
application_name?: string;
|
||||
}
|
||||
|
||||
@ -59,7 +59,6 @@ export default class DeviceMoveCmd extends Command {
|
||||
name: 'uuid',
|
||||
description:
|
||||
'comma-separated list (no blank spaces) of device UUIDs to be moved',
|
||||
parse: (dev) => tryAsInteger(dev),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
@ -81,16 +80,25 @@ export default class DeviceMoveCmd extends Command {
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
// Consolidate application options
|
||||
options.application = options.application || options.app;
|
||||
delete options.app;
|
||||
|
||||
const devices: ExtendedDevice[] = await Promise.all(
|
||||
params.uuid
|
||||
.split(',')
|
||||
.map((uuid) => balena.models.device.get(uuid, expandForAppName)),
|
||||
// Parse ids string into array of correct types
|
||||
const deviceIds: Array<string | number> = params.uuid
|
||||
.split(',')
|
||||
.map((id) => tryAsInteger(id));
|
||||
|
||||
// Get devices
|
||||
const devices = await Promise.all(
|
||||
deviceIds.map(
|
||||
(uuid) =>
|
||||
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 as Application[];
|
||||
device.application_name = belongsToApplication?.[0]
|
||||
@ -99,51 +107,12 @@ export default class DeviceMoveCmd extends Command {
|
||||
}
|
||||
|
||||
// Get destination application
|
||||
let application;
|
||||
if (options.application) {
|
||||
application = options.application;
|
||||
} else {
|
||||
const [deviceDeviceTypes, deviceTypes] = await Promise.all([
|
||||
Promise.all(
|
||||
devices.map((device) =>
|
||||
balena.models.device.getManifestBySlug(device.device_type),
|
||||
),
|
||||
),
|
||||
balena.models.config.getDeviceTypes(),
|
||||
]);
|
||||
const application =
|
||||
options.application ||
|
||||
(await this.interactivelySelectApplication(balena, devices));
|
||||
|
||||
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 {
|
||||
application = await patterns.selectApplication(
|
||||
(app: Application) =>
|
||||
compatibleDeviceTypes.some((dt) => dt.slug === app.device_type) &&
|
||||
// @ts-ignore using the extended device object prop
|
||||
devices.some((device) => device.application_name !== app.app_name),
|
||||
true,
|
||||
);
|
||||
} catch (err) {
|
||||
if (deviceDeviceTypes.length) {
|
||||
throw new ExpectedError(
|
||||
`${err.message}\nDo all devices have a compatible architecture?`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
for (const uuid of params.uuid.split(',')) {
|
||||
// Move each device
|
||||
for (const uuid of deviceIds) {
|
||||
try {
|
||||
await balena.models.device.move(uuid, tryAsInteger(application));
|
||||
console.info(`${uuid} was moved to ${application}`);
|
||||
@ -153,4 +122,53 @@ export default class DeviceMoveCmd extends Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async interactivelySelectApplication(
|
||||
balena: BalenaSDK,
|
||||
devices: ExtendedDevice[],
|
||||
) {
|
||||
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 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) =>
|
||||
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 (deviceDeviceTypes.length) {
|
||||
throw new ExpectedError(
|
||||
`${err.message}\nDo all devices have a compatible architecture?`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
@ -81,12 +81,17 @@ export default class DeviceOsUpdateCmd extends Command {
|
||||
// Get device info
|
||||
const {
|
||||
uuid,
|
||||
device_type,
|
||||
is_of__device_type,
|
||||
os_version,
|
||||
os_variant,
|
||||
} = await sdk.models.device.get(params.uuid, {
|
||||
$select: ['uuid', '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;
|
||||
|
||||
// Get current device OS version
|
||||
const currentOsVersion = sdk.models.device.getOsVersion({
|
||||
@ -101,7 +106,7 @@ export default class DeviceOsUpdateCmd extends Command {
|
||||
|
||||
// Get supported OS update versions
|
||||
const hupVersionInfo = await sdk.models.os.getSupportedOsUpdateVersions(
|
||||
device_type,
|
||||
is_of__device_type[0].slug,
|
||||
currentOsVersion,
|
||||
);
|
||||
if (hupVersionInfo.versions.length === 0) {
|
80
lib/commands/device/purge.ts
Normal file
80
lib/commands/device/purge.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @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 type { IArg } from '@oclif/parser/lib/args';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
|
||||
|
||||
interface FlagsDef {
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export default class DevicePurgeCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Purge application data from a device.
|
||||
|
||||
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).
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena device purge 23c73a1',
|
||||
'$ balena device purge 55d43b3,23c73a1',
|
||||
];
|
||||
|
||||
public static usage = 'device purge <uuid>';
|
||||
|
||||
public static args: Array<IArg<any>> = [
|
||||
{
|
||||
name: 'uuid',
|
||||
description: 'comma-separated list (no blank spaces) of device UUIDs',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(DevicePurgeCmd);
|
||||
|
||||
const { tryAsInteger } = await import('../../utils/validation');
|
||||
const balena = getBalenaSdk();
|
||||
const ux = getCliUx();
|
||||
|
||||
const deviceIds = params.uuid.split(',').map((id) => {
|
||||
return tryAsInteger(id);
|
||||
});
|
||||
|
||||
for (const deviceId of deviceIds) {
|
||||
ux.action.start(`Purging data from device ${deviceId}`);
|
||||
await balena.models.device.purge(deviceId);
|
||||
ux.action.stop();
|
||||
}
|
||||
}
|
||||
}
|
@ -20,7 +20,6 @@ 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 {
|
||||
uuid?: string;
|
||||
@ -46,7 +45,6 @@ export default class DeviceRegisterCmd extends Command {
|
||||
{
|
||||
name: 'application',
|
||||
description: 'the name or id of application to register device with',
|
||||
parse: (app) => tryAsInteger(app),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
@ -68,9 +66,11 @@ export default class DeviceRegisterCmd extends Command {
|
||||
DeviceRegisterCmd,
|
||||
);
|
||||
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
const application = await balena.models.application.get(params.application);
|
||||
const application = await getApplication(balena, params.application);
|
||||
const uuid = options.uuid ?? balena.models.device.generateUniqueKey();
|
||||
|
||||
console.info(`Registering to ${application.app_name}: ${uuid}`);
|
197
lib/commands/device/restart.ts
Normal file
197
lib/commands/device/restart.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @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 type { IArg } from '@oclif/parser/lib/args';
|
||||
import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
|
||||
import type {
|
||||
BalenaSDK,
|
||||
DeviceWithServiceDetails,
|
||||
CurrentServiceWithCommit,
|
||||
} from 'balena-sdk';
|
||||
|
||||
interface FlagsDef {
|
||||
help: void;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export default class DeviceRestartCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Restart containers on a device.
|
||||
|
||||
Restart containers on a device.
|
||||
If the --service flag is provided, then only those services' containers
|
||||
will be restarted, otherwise all containers on the device will be restarted.
|
||||
|
||||
Multiple devices and services may be specified with a comma-separated list
|
||||
of values (no spaces).
|
||||
|
||||
Note this does not reboot the device, to do so use instead \`balena device reboot\`.
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena device restart 23c73a1',
|
||||
'$ balena device restart 55d43b3,23c73a1',
|
||||
'$ balena device restart 23c73a1 --service myService',
|
||||
'$ balena device restart 23c73a1 -s myService1,myService2',
|
||||
];
|
||||
|
||||
public static args: Array<IArg<any>> = [
|
||||
{
|
||||
name: 'uuid',
|
||||
description:
|
||||
'comma-separated list (no blank spaces) of device UUIDs to restart',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'device restart <uuid>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
service: flags.string({
|
||||
description:
|
||||
'comma-separated list (no blank spaces) of service names to restart',
|
||||
char: 's',
|
||||
}),
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||
DeviceRestartCmd,
|
||||
);
|
||||
|
||||
const { tryAsInteger } = await import('../../utils/validation');
|
||||
const balena = getBalenaSdk();
|
||||
const ux = getCliUx();
|
||||
|
||||
const deviceIds = params.uuid.split(',').map((id) => {
|
||||
return tryAsInteger(id);
|
||||
});
|
||||
const serviceNames = options.service?.split(',');
|
||||
|
||||
// Iterate sequentially through deviceIds.
|
||||
// We may later want to add a batching feature,
|
||||
// so that n devices are processed in parallel
|
||||
for (const deviceId of deviceIds) {
|
||||
ux.action.start(`Restarting services on device ${deviceId}`);
|
||||
if (serviceNames) {
|
||||
await this.restartServices(balena, deviceId, serviceNames);
|
||||
} else {
|
||||
await this.restartAllServices(balena, deviceId);
|
||||
}
|
||||
ux.action.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async restartServices(
|
||||
balena: BalenaSDK,
|
||||
deviceId: number | string,
|
||||
serviceNames: string[],
|
||||
) {
|
||||
const { ExpectedError, instanceOf } = await import('../../errors');
|
||||
const { getExpandedProp } = await import('../../utils/pine');
|
||||
|
||||
// Get device
|
||||
let device: DeviceWithServiceDetails<CurrentServiceWithCommit>;
|
||||
try {
|
||||
device = await balena.models.device.getWithServiceDetails(deviceId, {
|
||||
$expand: {
|
||||
is_running__release: { $select: 'commit' },
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const { BalenaDeviceNotFound } = await import('balena-errors');
|
||||
if (instanceOf(e, BalenaDeviceNotFound)) {
|
||||
throw new ExpectedError(`Device ${deviceId} not found.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const activeRelease = getExpandedProp(device.is_running__release, 'commit');
|
||||
|
||||
// Check specified services exist on this device before restarting anything
|
||||
serviceNames.forEach((service) => {
|
||||
if (!device.current_services[service]) {
|
||||
throw new ExpectedError(
|
||||
`Service ${service} not found on device ${deviceId}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart services
|
||||
const restartPromises: Array<Promise<void>> = [];
|
||||
for (const serviceName of serviceNames) {
|
||||
const service = device.current_services[serviceName];
|
||||
// Each service is an array of `CurrentServiceWithCommit`
|
||||
// because when service is updating, it will actually hold 2 services
|
||||
// Target commit matching `device.is_running__release`
|
||||
const serviceContainer = service.find((s) => {
|
||||
return s.commit === activeRelease;
|
||||
});
|
||||
|
||||
if (serviceContainer) {
|
||||
restartPromises.push(
|
||||
balena.models.device.restartService(
|
||||
deviceId,
|
||||
serviceContainer.image_id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(restartPromises);
|
||||
} catch (e) {
|
||||
if (e.message.toLowerCase().includes('no online device')) {
|
||||
throw new ExpectedError(`Device ${deviceId} is not online.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restartAllServices(balena: BalenaSDK, deviceId: number | string) {
|
||||
// Note: device.restartApplication throws `BalenaDeviceNotFound: Device not found` if device not online.
|
||||
// Need to use device.get first to distinguish between non-existant and offline devices.
|
||||
// Remove this workaround when SDK issue resolved: https://github.com/balena-io/balena-sdk/issues/649
|
||||
const { instanceOf, ExpectedError } = await import('../../errors');
|
||||
try {
|
||||
const device = await balena.models.device.get(deviceId);
|
||||
if (!device.is_online) {
|
||||
throw new ExpectedError(`Device ${deviceId} is not online.`);
|
||||
}
|
||||
} catch (e) {
|
||||
const { BalenaDeviceNotFound } = await import('balena-errors');
|
||||
if (instanceOf(e, BalenaDeviceNotFound)) {
|
||||
throw new ExpectedError(`Device ${deviceId} not found.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await balena.models.device.restartApplication(deviceId);
|
||||
}
|
||||
}
|
@ -33,28 +33,29 @@ interface ArgsDef {
|
||||
|
||||
export default class DeviceRmCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Remove a device.
|
||||
Remove one or more devices.
|
||||
|
||||
Remove a device from balena.
|
||||
Remove one or more devices from balena.
|
||||
|
||||
Note this command asks for confirmation interactively.
|
||||
You can avoid this by passing the \`--yes\` option.
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena device rm 7cf02a6',
|
||||
'$ balena device rm 7cf02a6,dc39e52',
|
||||
'$ balena device rm 7cf02a6 --yes',
|
||||
];
|
||||
|
||||
public static args: Array<IArg<any>> = [
|
||||
{
|
||||
name: 'uuid',
|
||||
description: 'the uuid of the device to remove',
|
||||
parse: (dev) => tryAsInteger(dev),
|
||||
description:
|
||||
'comma-separated list (no blank spaces) of device UUIDs to be removed',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'device rm <uuid>';
|
||||
public static usage = 'device rm <uuid(s)>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
yes: cf.yes,
|
||||
@ -72,12 +73,23 @@ export default class DeviceRmCmd extends Command {
|
||||
const patterns = await import('../../utils/patterns');
|
||||
|
||||
// Confirm
|
||||
const uuids = params.uuid.split(',');
|
||||
await patterns.confirm(
|
||||
options.yes,
|
||||
'Are you sure you want to delete the device?',
|
||||
uuids.length > 1
|
||||
? `Are you sure you want to delete ${uuids.length} devices?`
|
||||
: `Are you sure you want to delete device ${uuids[0]} ?`,
|
||||
);
|
||||
|
||||
// Remove
|
||||
await balena.models.device.remove(params.uuid);
|
||||
for (const uuid of uuids) {
|
||||
try {
|
||||
await balena.models.device.remove(tryAsInteger(uuid));
|
||||
} catch (err) {
|
||||
console.info(`${err.message}, uuid: ${uuid}`);
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -21,17 +21,19 @@ 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 { Device, Application } from 'balena-sdk';
|
||||
import type { Application } from 'balena-sdk';
|
||||
|
||||
interface ExtendedDevice extends Device {
|
||||
interface ExtendedDevice extends DeviceWithDeviceType {
|
||||
dashboard_url?: string;
|
||||
application_name?: string;
|
||||
application_name?: string | null;
|
||||
device_type?: string | null;
|
||||
}
|
||||
|
||||
interface FlagsDef {
|
||||
application?: string;
|
||||
app?: string;
|
||||
help: void;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export default class DevicesCmd extends Command {
|
||||
@ -41,6 +43,12 @@ export default class DevicesCmd extends Command {
|
||||
list all devices that belong to you.
|
||||
|
||||
You can filter the devices by application by using the \`--application\` option.
|
||||
|
||||
The --json option is recommended when scripting the output of this command,
|
||||
because field names are less likely to change in JSON format and because it
|
||||
better represents data types like arrays, empty strings and null values.
|
||||
The 'jq' utility may be helpful for querying JSON fields in shell scripts
|
||||
(https://stedolan.github.io/jq/manual/).
|
||||
`;
|
||||
public static examples = [
|
||||
'$ balena devices',
|
||||
@ -55,6 +63,10 @@ export default class DevicesCmd extends Command {
|
||||
application: cf.application,
|
||||
app: cf.app,
|
||||
help: cf.help,
|
||||
json: flags.boolean({
|
||||
char: 'j',
|
||||
description: 'produce JSON output instead of tabular output',
|
||||
}),
|
||||
};
|
||||
|
||||
public static primary = true;
|
||||
@ -70,42 +82,59 @@ export default class DevicesCmd extends Command {
|
||||
options.application = options.application || options.app;
|
||||
delete options.app;
|
||||
|
||||
let devices: ExtendedDevice[];
|
||||
let devices;
|
||||
|
||||
if (options.application != null) {
|
||||
devices = await balena.models.device.getAllByApplication(
|
||||
devices = (await balena.models.device.getAllByApplication(
|
||||
tryAsInteger(options.application),
|
||||
expandForAppName,
|
||||
);
|
||||
)) as ExtendedDevice[];
|
||||
} else {
|
||||
devices = await balena.models.device.getAll(expandForAppName);
|
||||
devices = (await balena.models.device.getAll(
|
||||
expandForAppName,
|
||||
)) as ExtendedDevice[];
|
||||
}
|
||||
|
||||
devices = devices.map(function (device) {
|
||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
|
||||
|
||||
const belongsToApplication = device.belongs_to__application as Application[];
|
||||
device.application_name = belongsToApplication?.[0]
|
||||
? belongsToApplication[0].app_name
|
||||
: 'N/a';
|
||||
device.application_name = belongsToApplication?.[0]?.app_name || null;
|
||||
|
||||
device.uuid = device.uuid.slice(0, 7);
|
||||
|
||||
device.device_type = device.is_of__device_type?.[0]?.slug || null;
|
||||
return device;
|
||||
});
|
||||
|
||||
console.log(
|
||||
getVisuals().table.horizontal(devices, [
|
||||
'id',
|
||||
'uuid',
|
||||
'device_name',
|
||||
'device_type',
|
||||
'application_name',
|
||||
'status',
|
||||
'is_online',
|
||||
'supervisor_version',
|
||||
'os_version',
|
||||
'dashboard_url',
|
||||
]),
|
||||
);
|
||||
const fields = [
|
||||
'id',
|
||||
'uuid',
|
||||
'device_name',
|
||||
'device_type',
|
||||
'application_name',
|
||||
'status',
|
||||
'is_online',
|
||||
'supervisor_version',
|
||||
'os_version',
|
||||
'dashboard_url',
|
||||
];
|
||||
const _ = await import('lodash');
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
devices.map((device) => _.pick(device, fields)),
|
||||
null,
|
||||
4,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
getVisuals().table.horizontal(
|
||||
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),
|
||||
fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -76,9 +76,9 @@ export default class DevicesSupportedCmd extends Command {
|
||||
|
||||
public async run() {
|
||||
const { flags: options } = this.parse<FlagsDef, {}>(DevicesSupportedCmd);
|
||||
let deviceTypes: Array<Partial<SDK.DeviceType>> = await getBalenaSdk()
|
||||
.models.config.getDeviceTypes()
|
||||
.map((d) => {
|
||||
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);
|
||||
@ -91,7 +91,8 @@ export default class DevicesSupportedCmd extends Command {
|
||||
d.aliases = [];
|
||||
}
|
||||
return d;
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!options.discontinued) {
|
||||
deviceTypes = deviceTypes.filter((dt) => dt.state !== 'DISCONTINUED');
|
||||
}
|
||||
@ -100,7 +101,7 @@ export default class DevicesSupportedCmd extends Command {
|
||||
: ['slug', 'aliases', 'arch', 'name'];
|
||||
deviceTypes = _.sortBy(
|
||||
deviceTypes.map((d) => {
|
||||
const picked = _.pick<Partial<SDK.DeviceType>>(d, fields);
|
||||
const picked = _.pick(d, fields);
|
||||
// 'BETA' renamed to 'NEW'
|
||||
picked.state = picked.state === 'BETA' ? 'NEW' : picked.state;
|
||||
return picked;
|
@ -22,7 +22,6 @@ import Command from '../../command';
|
||||
import { ExpectedError } from '../../errors';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||
import { CommandHelp } from '../../utils/oclif-utils';
|
||||
|
||||
interface FlagsDef {
|
||||
application?: string; // application name
|
||||
@ -39,17 +38,18 @@ interface ArgsDef {
|
||||
|
||||
export default class EnvAddCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Add an environment or config variable to an application, device or service.
|
||||
Add env or config variable to application(s), device(s) or service(s).
|
||||
|
||||
Add an environment or config variable to an application, device or service,
|
||||
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 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 application (i.e., the application's fleet). If the --service
|
||||
option is omitted, the variable applies to all services.
|
||||
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 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 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
|
||||
@ -67,9 +67,13 @@ export default class EnvAddCmd extends Command {
|
||||
public static examples = [
|
||||
'$ balena env add TERM --application MyApp',
|
||||
'$ balena env add EDITOR vim --application 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',
|
||||
'$ balena env add EDITOR vim --device 7cf02a6,d6f1433 --service MyService,MyService2',
|
||||
];
|
||||
|
||||
public static args = [
|
||||
@ -86,9 +90,7 @@ export default class EnvAddCmd extends Command {
|
||||
},
|
||||
];
|
||||
|
||||
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
|
||||
public static usage =
|
||||
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
|
||||
public static usage = 'env add <name> [value]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
application: { exclusive: ['device'], ...cf.application },
|
||||
@ -147,17 +149,31 @@ export default class EnvAddCmd extends Command {
|
||||
|
||||
const varType = isConfigVar ? 'configVar' : 'envVar';
|
||||
if (options.application) {
|
||||
await balena.models.application[varType].set(
|
||||
options.application,
|
||||
params.name,
|
||||
params.value,
|
||||
);
|
||||
for (const app of options.application.split(',')) {
|
||||
try {
|
||||
await balena.models.application[varType].set(
|
||||
app,
|
||||
params.name,
|
||||
params.value,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${err.message}, app: ${app}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} else if (options.device) {
|
||||
await balena.models.device[varType].set(
|
||||
options.device,
|
||||
params.name,
|
||||
params.value,
|
||||
);
|
||||
for (const device of options.device.split(',')) {
|
||||
try {
|
||||
await balena.models.device[varType].set(
|
||||
device,
|
||||
params.name,
|
||||
params.value,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${err.message}, device: ${device}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -171,31 +187,57 @@ async function setServiceVars(
|
||||
options: FlagsDef,
|
||||
) {
|
||||
if (options.application) {
|
||||
const serviceId = await getServiceIdForApp(
|
||||
sdk,
|
||||
options.application,
|
||||
options.service!,
|
||||
);
|
||||
await sdk.models.service.var.set(serviceId, params.name, params.value!);
|
||||
} else {
|
||||
for (const app of options.application.split(',')) {
|
||||
for (const service of options.service!.split(',')) {
|
||||
try {
|
||||
const serviceId = await getServiceIdForApp(sdk, app, service);
|
||||
await sdk.models.service.var.set(
|
||||
serviceId,
|
||||
params.name,
|
||||
params.value!,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${err.message}, application: ${app}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (options.device) {
|
||||
const { getDeviceAndAppFromUUID } = await import('../../utils/cloud');
|
||||
const [device, app] = await getDeviceAndAppFromUUID(
|
||||
sdk,
|
||||
options.device!,
|
||||
['id'],
|
||||
['app_name'],
|
||||
);
|
||||
const serviceId = await getServiceIdForApp(
|
||||
sdk,
|
||||
app.app_name,
|
||||
options.service!,
|
||||
);
|
||||
await sdk.models.device.serviceVar.set(
|
||||
device.id,
|
||||
serviceId,
|
||||
params.name,
|
||||
params.value!,
|
||||
);
|
||||
for (const uuid of options.device.split(',')) {
|
||||
let device;
|
||||
let app;
|
||||
try {
|
||||
[device, app] = await getDeviceAndAppFromUUID(
|
||||
sdk,
|
||||
uuid,
|
||||
['id'],
|
||||
['app_name'],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${err.message}, device: ${uuid}`);
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
for (const service of options.service!.split(',')) {
|
||||
try {
|
||||
const serviceId = await getServiceIdForApp(
|
||||
sdk,
|
||||
app.app_name,
|
||||
service,
|
||||
);
|
||||
await sdk.models.device.serviceVar.set(
|
||||
device.id,
|
||||
serviceId,
|
||||
params.name,
|
||||
params.value!,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`${err.message}, service: ${service}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,6 @@ import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import * as ec from '../../utils/env-common';
|
||||
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||
import { CommandHelp } from '../../utils/oclif-utils';
|
||||
import { parseAsInteger } from '../../utils/validation';
|
||||
|
||||
type IArg<T> = import('@oclif/parser').args.IArg<T>;
|
||||
@ -70,9 +69,7 @@ export default class EnvRenameCmd extends Command {
|
||||
},
|
||||
];
|
||||
|
||||
// hardcoded 'env rename' to avoid oclif's 'env:rename' topic syntax
|
||||
public static usage =
|
||||
'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage();
|
||||
public static usage = 'env rename <id> <value>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
config: ec.booleanConfig,
|
@ -20,7 +20,6 @@ import Command from '../../command';
|
||||
|
||||
import * as ec from '../../utils/env-common';
|
||||
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
|
||||
import { CommandHelp } from '../../utils/oclif-utils';
|
||||
import { parseAsInteger } from '../../utils/validation';
|
||||
|
||||
type IArg<T> = import('@oclif/parser').args.IArg<T>;
|
||||
@ -67,9 +66,7 @@ export default class EnvRmCmd extends Command {
|
||||
},
|
||||
];
|
||||
|
||||
// hardcoded 'env rm' to avoid oclif's 'env:rm' topic syntax
|
||||
public static usage =
|
||||
'env rm ' + new CommandHelp({ args: EnvRmCmd.args }).defaultUsage();
|
||||
public static usage = 'env rm <id>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
config: ec.booleanConfig,
|
@ -317,7 +317,7 @@ async function getAppVars(
|
||||
appVars.push(...vars);
|
||||
}
|
||||
if (!options.config && (options.service || options.all)) {
|
||||
const pineOpts: SDK.PineOptionsFor<SDK.ServiceEnvironmentVariable> = {
|
||||
const pineOpts: SDK.PineOptions<SDK.ServiceEnvironmentVariable> = {
|
||||
$expand: {
|
||||
service: {},
|
||||
},
|
||||
@ -359,7 +359,7 @@ async function getDeviceVars(
|
||||
deviceVars.push(...deviceConfigVars);
|
||||
} else {
|
||||
if (options.service || options.all) {
|
||||
const pineOpts: SDK.PineOptionsFor<SDK.DeviceServiceEnvironmentVariable> = {
|
||||
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
|
||||
$expand: {
|
||||
service_install: {
|
||||
$expand: 'installs__service',
|
@ -40,7 +40,16 @@ export default class ScandevicesCmd extends Command {
|
||||
|
||||
public async run() {
|
||||
const { forms } = await import('balena-sync');
|
||||
const hostnameOrIp = await forms.selectLocalBalenaOsDevice();
|
||||
return console.error(`==> Selected device: ${hostnameOrIp}`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,9 +19,11 @@ import { flags } from '@oclif/command';
|
||||
import Command from '../command';
|
||||
import * as cf from '../utils/common-flags';
|
||||
import { getBalenaSdk, stripIndent } from '../utils/lazy';
|
||||
import { parseAsLocalHostnameOrIp } from '../utils/validation';
|
||||
|
||||
interface FlagsDef {
|
||||
application?: string;
|
||||
pollInterval?: number;
|
||||
help?: void;
|
||||
}
|
||||
|
||||
@ -61,6 +63,7 @@ export default class JoinCmd extends Command {
|
||||
{
|
||||
name: 'deviceIpOrHostname',
|
||||
description: 'the IP or hostname of device',
|
||||
parse: parseAsLocalHostnameOrIp,
|
||||
},
|
||||
];
|
||||
|
||||
@ -72,6 +75,10 @@ export default class JoinCmd extends Command {
|
||||
description: 'the name of the application the device should join',
|
||||
...cf.application,
|
||||
},
|
||||
pollInterval: flags.integer({
|
||||
description: 'the interval in minutes to check for updates',
|
||||
char: 'i',
|
||||
}),
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
@ -83,15 +90,15 @@ export default class JoinCmd extends Command {
|
||||
JoinCmd,
|
||||
);
|
||||
|
||||
const Logger = await import('../utils/logger');
|
||||
const promote = await import('../utils/promote');
|
||||
const sdk = getBalenaSdk();
|
||||
const logger = Logger.getLogger();
|
||||
const logger = await Command.getLogger();
|
||||
return promote.join(
|
||||
logger,
|
||||
sdk,
|
||||
params.deviceIpOrHostname,
|
||||
options.application,
|
||||
options.pollInterval,
|
||||
);
|
||||
}
|
||||
}
|
@ -34,15 +34,30 @@ export default class KeyAddCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Add an SSH key to balenaCloud.
|
||||
|
||||
Register an SSH in balenaCloud for the logged in user.
|
||||
Add an SSH key to the balenaCloud account of the logged in user.
|
||||
|
||||
If \`path\` is omitted, the command will attempt
|
||||
to read the SSH key from stdin.
|
||||
If \`path\` is omitted, the command will attempt to read the SSH key from stdin.
|
||||
|
||||
About SSH keys
|
||||
An "SSH key" actually consists of a public/private key pair. A typical name
|
||||
for the private key file is "id_rsa", and a typical name for the public key
|
||||
file is "id_rsa.pub". Both key files are saved to your computer (with the
|
||||
private key optionally protected by a password), but only the public key is
|
||||
saved to your balena account. This means that if you change computers or
|
||||
otherwise lose the private key, you cannot recover the private key through
|
||||
your balena account. You can however add new keys, and delete the old ones.
|
||||
|
||||
To generate a new SSH key pair, a nice guide can be found in GitHub's docs:
|
||||
https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent
|
||||
Skip the step about adding the key to a GitHub account, and instead add it to
|
||||
your balena account.
|
||||
`;
|
||||
|
||||
public static examples = [
|
||||
'$ balena key add Main ~/.ssh/id_rsa.pub',
|
||||
'$ cat ~/.ssh/id_rsa.pub | balena key add Main',
|
||||
'# Windows 10 (cmd.exe prompt) example',
|
||||
'$ balena key add Main %userprofile%.sshid_rsa.pub',
|
||||
];
|
||||
|
||||
public static args = [
|
@ -19,6 +19,7 @@ import { flags } from '@oclif/command';
|
||||
import Command from '../command';
|
||||
import * as cf from '../utils/common-flags';
|
||||
import { getBalenaSdk, stripIndent } from '../utils/lazy';
|
||||
import { parseAsLocalHostnameOrIp } from '../utils/validation';
|
||||
|
||||
interface FlagsDef {
|
||||
help?: void;
|
||||
@ -54,10 +55,10 @@ export default class LeaveCmd extends Command {
|
||||
{
|
||||
name: 'deviceIpOrHostname',
|
||||
description: 'the device IP or hostname',
|
||||
parse: parseAsLocalHostnameOrIp,
|
||||
},
|
||||
];
|
||||
|
||||
// Hardcoded to preserve camelcase
|
||||
public static usage = 'leave [deviceIpOrHostname]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
@ -70,10 +71,9 @@ export default class LeaveCmd extends Command {
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(LeaveCmd);
|
||||
|
||||
const Logger = await import('../utils/logger');
|
||||
const promote = await import('../utils/promote');
|
||||
const sdk = getBalenaSdk();
|
||||
const logger = Logger.getLogger();
|
||||
const logger = await Command.getLogger();
|
||||
return promote.leave(logger, sdk, params.deviceIpOrHostname);
|
||||
}
|
||||
}
|
@ -59,13 +59,13 @@ export default class LocalConfigureCmd extends Command {
|
||||
public async run() {
|
||||
const { args: params } = this.parse<FlagsDef, ArgsDef>(LocalConfigureCmd);
|
||||
|
||||
const Bluebird = await import('bluebird');
|
||||
const { promisify } = await import('util');
|
||||
const path = await import('path');
|
||||
const umount = await import('umount');
|
||||
const umountAsync = Bluebird.promisify(umount.umount);
|
||||
const isMountedAsync = Bluebird.promisify(umount.isMounted);
|
||||
const umountAsync = promisify(umount.umount);
|
||||
const isMountedAsync = promisify(umount.isMounted);
|
||||
const reconfix = await import('reconfix');
|
||||
const denymount = Bluebird.promisify(await import('denymount'));
|
||||
const denymount = promisify(await import('denymount'));
|
||||
const Logger = await import('../../utils/logger');
|
||||
|
||||
const logger = Logger.getLogger();
|
||||
@ -89,20 +89,17 @@ export default class LocalConfigureCmd extends Command {
|
||||
const dmHandler = (cb: () => void) =>
|
||||
reconfix
|
||||
.readConfiguration(configurationSchema, params.target)
|
||||
.tap((config: any) => {
|
||||
.then(async (config: any) => {
|
||||
logger.logDebug('Current config:');
|
||||
logger.logDebug(JSON.stringify(config));
|
||||
})
|
||||
.then((config: any) => this.getConfiguration(config))
|
||||
.tap((config: any) => {
|
||||
const answers = await this.getConfiguration(config);
|
||||
logger.logDebug('New config:');
|
||||
logger.logDebug(JSON.stringify(config));
|
||||
})
|
||||
.then(async (answers: any) => {
|
||||
logger.logDebug(JSON.stringify(answers));
|
||||
|
||||
if (!answers.hostname) {
|
||||
await this.removeHostname(configurationSchema);
|
||||
}
|
||||
return reconfix.writeConfiguration(
|
||||
return await reconfix.writeConfiguration(
|
||||
configurationSchema,
|
||||
answers,
|
||||
params.target,
|
||||
@ -119,9 +116,7 @@ export default class LocalConfigureCmd extends Command {
|
||||
readonly CONNECTIONS_FOLDER = '/system-connections';
|
||||
|
||||
getConfigurationSchema(connectionFileName?: string) {
|
||||
if (connectionFileName == null) {
|
||||
connectionFileName = 'resin-wifi';
|
||||
}
|
||||
connectionFileName ??= 'resin-wifi';
|
||||
return {
|
||||
mapper: [
|
||||
{
|
||||
@ -222,9 +217,8 @@ export default class LocalConfigureCmd extends Command {
|
||||
persistentLogging: data.persistentLogging || false,
|
||||
});
|
||||
|
||||
return inquirer
|
||||
.prompt(this.inquirerOptions(data))
|
||||
.then((answers: any) => _.merge(data, answers));
|
||||
const answers = await inquirer.prompt(this.inquirerOptions(data));
|
||||
return _.merge(data, answers);
|
||||
};
|
||||
|
||||
// Taken from https://goo.gl/kr1kCt
|
||||
@ -261,62 +255,50 @@ export default class LocalConfigureCmd extends Command {
|
||||
const _ = await import('lodash');
|
||||
const imagefs = await import('resin-image-fs');
|
||||
|
||||
return imagefs
|
||||
.listDirectory({
|
||||
image: target,
|
||||
partition: this.BOOT_PARTITION,
|
||||
path: this.CONNECTIONS_FOLDER,
|
||||
})
|
||||
.then((files: string[]) => {
|
||||
// The required file already exists
|
||||
if (_.includes(files, 'resin-wifi')) {
|
||||
return null;
|
||||
}
|
||||
const files = await imagefs.listDirectory({
|
||||
image: target,
|
||||
partition: this.BOOT_PARTITION,
|
||||
path: this.CONNECTIONS_FOLDER,
|
||||
});
|
||||
|
||||
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
|
||||
if (_.includes(files, 'resin-sample.ignore')) {
|
||||
return 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`,
|
||||
},
|
||||
)
|
||||
.thenReturn(null);
|
||||
}
|
||||
|
||||
// Legacy mode, to be removed later
|
||||
// We return the file name override from this branch
|
||||
// When it is removed the following cleanup should be done:
|
||||
// * delete all the null returns from this method
|
||||
// * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
|
||||
// * drop the final `then` from this method
|
||||
// * adapt the code in the main listener to not receive the config from this method, and use that constant instead
|
||||
if (_.includes(files, 'resin-sample')) {
|
||||
return 'resin-sample';
|
||||
}
|
||||
|
||||
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
|
||||
return imagefs
|
||||
.writeFile(
|
||||
{
|
||||
image: target,
|
||||
partition: this.BOOT_PARTITION,
|
||||
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
|
||||
},
|
||||
this.CONNECTION_FILE,
|
||||
)
|
||||
.thenReturn(null);
|
||||
})
|
||||
.then((connectionFileName) =>
|
||||
this.getConfigurationSchema(connectionFileName || undefined),
|
||||
let connectionFileName;
|
||||
if (_.includes(files, 'resin-wifi')) {
|
||||
// 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.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
|
||||
// When it is removed the following cleanup should be done:
|
||||
// * delete all the null returns from this method
|
||||
// * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
|
||||
// * drop the final `then` from this method
|
||||
// * adapt the code in the main listener to not receive the config from this method, and use that constant instead
|
||||
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.writeFile(
|
||||
{
|
||||
image: target,
|
||||
partition: this.BOOT_PARTITION,
|
||||
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
|
||||
},
|
||||
this.CONNECTION_FILE,
|
||||
);
|
||||
}
|
||||
return await this.getConfigurationSchema(connectionFileName);
|
||||
}
|
||||
|
||||
async removeHostname(schema: any) {
|
@ -28,6 +28,7 @@ interface FlagsDef {
|
||||
email?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
help: void;
|
||||
}
|
||||
|
||||
@ -73,14 +74,17 @@ export default class LoginCmd extends Command {
|
||||
web: flags.boolean({
|
||||
char: 'w',
|
||||
description: 'web-based login',
|
||||
exclusive: ['token', 'credentials'],
|
||||
}),
|
||||
token: flags.boolean({
|
||||
char: 't',
|
||||
description: 'session token or API key',
|
||||
exclusive: ['web', 'credentials'],
|
||||
}),
|
||||
credentials: flags.boolean({
|
||||
char: 'c',
|
||||
description: 'credential-based login',
|
||||
exclusive: ['web', 'token'],
|
||||
}),
|
||||
email: flags.string({
|
||||
char: 'e',
|
||||
@ -101,6 +105,12 @@ export default class LoginCmd extends Command {
|
||||
description: 'password',
|
||||
dependsOn: ['credentials'],
|
||||
}),
|
||||
port: flags.integer({
|
||||
char: 'P',
|
||||
description:
|
||||
'TCP port number of local HTTP login server (--web auth only)',
|
||||
dependsOn: ['web'],
|
||||
}),
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
@ -122,7 +132,7 @@ export default class LoginCmd extends Command {
|
||||
|
||||
console.log(messages.balenaAsciiArt);
|
||||
console.log(`\nLogging in to ${balenaUrl}`);
|
||||
await this.doLogin(options, params.token);
|
||||
await this.doLogin(options, balenaUrl, params.token);
|
||||
|
||||
const username = await balena.auth.whoami();
|
||||
|
||||
@ -134,14 +144,13 @@ Find out about the available commands by running:
|
||||
$ balena help
|
||||
|
||||
${messages.reachingOut}`);
|
||||
|
||||
if (options.web) {
|
||||
const { shutdownServer } = await import('../auth');
|
||||
shutdownServer();
|
||||
}
|
||||
}
|
||||
|
||||
async doLogin(loginOptions: FlagsDef, token?: string): Promise<void> {
|
||||
async doLogin(
|
||||
loginOptions: FlagsDef,
|
||||
balenaUrl: string = 'balena-cloud.com',
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
// Token
|
||||
if (loginOptions.token) {
|
||||
if (!token) {
|
||||
@ -166,15 +175,15 @@ ${messages.reachingOut}`);
|
||||
// Web
|
||||
else if (loginOptions.web) {
|
||||
const auth = await import('../auth');
|
||||
await auth.login();
|
||||
await auth.login({ port: loginOptions.port });
|
||||
return;
|
||||
} else {
|
||||
const patterns = await import('../utils/patterns');
|
||||
// User had not selected login preference, prompt interactively
|
||||
const loginType = await patterns.askLoginType();
|
||||
if (loginType === 'register') {
|
||||
const signupUrl = 'https://dashboard.balena-cloud.com/signup';
|
||||
const open = await import('open');
|
||||
const signupUrl = `https://dashboard.${balenaUrl}/signup`;
|
||||
open(signupUrl, { wait: false });
|
||||
throw new ExpectedError(`Please sign up at ${signupUrl}`);
|
||||
}
|
@ -115,10 +115,8 @@ export default class LogsCmd extends Command {
|
||||
|
||||
const displayCloudLog = async (line: LogMessage) => {
|
||||
if (!line.isSystem) {
|
||||
let serviceName = await serviceIdToName(balena, line.serviceId);
|
||||
if (serviceName == null) {
|
||||
serviceName = 'Unknown service';
|
||||
}
|
||||
const serviceName =
|
||||
(await serviceIdToName(balena, line.serviceId)) ?? 'Unknown service';
|
||||
displayLogObject(
|
||||
{ serviceName, ...line },
|
||||
logger,
|
@ -20,7 +20,7 @@ import Command from '../../command';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getCliForm, stripIndent } from '../../utils/lazy';
|
||||
import * as _ from 'lodash';
|
||||
import type { DeviceType } from 'balena-sdk';
|
||||
import type { DeviceTypeJson } from 'balena-sdk';
|
||||
|
||||
interface FlagsDef {
|
||||
advanced: boolean;
|
||||
@ -91,7 +91,7 @@ export default class OsBuildConfigCmd extends Command {
|
||||
|
||||
await writeFile(options.output, JSON.stringify(config, null, 4));
|
||||
|
||||
console.info(`Config file ${params.image} created successfully.`);
|
||||
console.info(`Config file "${options.output}" created successfully.`);
|
||||
}
|
||||
|
||||
async buildConfig(image: string, deviceTypeSlug: string, advanced: boolean) {
|
||||
@ -100,16 +100,14 @@ export default class OsBuildConfigCmd extends Command {
|
||||
const { getManifest } = await import('../../utils/helpers');
|
||||
|
||||
const deviceTypeManifest = await getManifest(image, deviceTypeSlug);
|
||||
await this.buildConfigForDeviceType(deviceTypeManifest, advanced);
|
||||
return this.buildConfigForDeviceType(deviceTypeManifest, advanced);
|
||||
}
|
||||
|
||||
async buildConfigForDeviceType(
|
||||
deviceTypeManifest: DeviceType,
|
||||
deviceTypeManifest: DeviceTypeJson.DeviceType,
|
||||
advanced: boolean,
|
||||
) {
|
||||
if (advanced == null) {
|
||||
advanced = false;
|
||||
}
|
||||
advanced ??= false;
|
||||
|
||||
let override;
|
||||
const questions = deviceTypeManifest.options;
|
@ -24,7 +24,6 @@ import Command from '../../command';
|
||||
import { ExpectedError } from '../../errors';
|
||||
import * as cf from '../../utils/common-flags';
|
||||
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
|
||||
import { CommandHelp } from '../../utils/oclif-utils';
|
||||
|
||||
const BOOT_PARTITION = 1;
|
||||
const CONNECTIONS_FOLDER = '/system-connections';
|
||||
@ -51,10 +50,6 @@ interface ArgsDef {
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface DeferredDevice extends BalenaSdk.Device {
|
||||
belongs_to__application: BalenaSdk.PineDeferred;
|
||||
}
|
||||
|
||||
interface Answers {
|
||||
appUpdatePollInterval: number; // in minutes
|
||||
deviceType: string; // e.g. "raspberrypi3"
|
||||
@ -115,10 +110,7 @@ export default class OsConfigureCmd extends Command {
|
||||
},
|
||||
];
|
||||
|
||||
// hardcoded 'os configure' to avoid oclif's 'os:configure' topic syntax
|
||||
public static usage =
|
||||
'os configure ' +
|
||||
new CommandHelp({ args: OsConfigureCmd.args }).defaultUsage();
|
||||
public static usage = 'os configure <image>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
advanced: flags.boolean({
|
||||
@ -192,18 +184,31 @@ export default class OsConfigureCmd extends Command {
|
||||
'../../utils/config'
|
||||
);
|
||||
const helpers = await import('../../utils/helpers');
|
||||
let app: BalenaSdk.Application | undefined;
|
||||
let device: BalenaSdk.Device | undefined;
|
||||
const { getApplication } = await import('../../utils/sdk');
|
||||
|
||||
let app: ApplicationWithDeviceType | undefined;
|
||||
let device;
|
||||
let deviceTypeSlug: string;
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
if (options.device) {
|
||||
device = await balena.models['device'].get(options.device);
|
||||
deviceTypeSlug = device.device_type;
|
||||
device = (await balena.models.device.get(options.device, {
|
||||
$expand: {
|
||||
is_of__device_type: { $select: 'slug' },
|
||||
},
|
||||
})) as DeviceWithDeviceType & {
|
||||
belongs_to__application: BalenaSdk.PineDeferred;
|
||||
};
|
||||
deviceTypeSlug = device.is_of__device_type[0].slug;
|
||||
} else {
|
||||
app = await balena.models['application'].get(options.application!);
|
||||
app = (await getApplication(balena, options.application!, {
|
||||
$expand: {
|
||||
is_for__device_type: { $select: 'slug' },
|
||||
},
|
||||
})) as ApplicationWithDeviceType;
|
||||
await checkDeviceTypeCompatibility(balena, options, app);
|
||||
deviceTypeSlug = options['device-type'] || app.device_type;
|
||||
deviceTypeSlug =
|
||||
options['device-type'] || app.is_for__device_type[0].slug;
|
||||
}
|
||||
|
||||
const deviceTypeManifest = await helpers.getManifest(
|
||||
@ -232,7 +237,7 @@ export default class OsConfigureCmd extends Command {
|
||||
if (_.isEmpty(configJson)) {
|
||||
if (device) {
|
||||
configJson = await generateDeviceConfig(
|
||||
device as DeferredDevice,
|
||||
device,
|
||||
options['device-api-key'],
|
||||
answers,
|
||||
);
|
||||
@ -335,7 +340,7 @@ async function validateOptions(options: FlagsDef) {
|
||||
*/
|
||||
async function getOsVersionFromImage(
|
||||
imagePath: string,
|
||||
deviceTypeManifest: BalenaSdk.DeviceType,
|
||||
deviceTypeManifest: BalenaSdk.DeviceTypeJson.DeviceType,
|
||||
devInit: typeof import('balena-device-init'),
|
||||
): Promise<string> {
|
||||
const osVersion = await devInit.getImageOsVersion(
|
||||
@ -361,11 +366,11 @@ async function getOsVersionFromImage(
|
||||
async function checkDeviceTypeCompatibility(
|
||||
sdk: BalenaSdk.BalenaSDK,
|
||||
options: FlagsDef,
|
||||
app: BalenaSdk.Application,
|
||||
app: ApplicationWithDeviceType,
|
||||
) {
|
||||
if (options['device-type']) {
|
||||
const [appDeviceType, optionDeviceType] = await Promise.all([
|
||||
sdk.models.device.getManifestBySlug(app.device_type),
|
||||
sdk.models.device.getManifestBySlug(app.is_for__device_type[0].slug),
|
||||
sdk.models.device.getManifestBySlug(options['device-type']),
|
||||
]);
|
||||
const helpers = await import('../../utils/helpers');
|
||||
@ -392,7 +397,7 @@ async function checkDeviceTypeCompatibility(
|
||||
* The questions are extracted from the given deviceType "manifest".
|
||||
*/
|
||||
async function askQuestionsForDeviceType(
|
||||
deviceType: BalenaSdk.DeviceType,
|
||||
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
|
||||
options: FlagsDef,
|
||||
configJson?: import('../../utils/config').ImgConfig,
|
||||
): Promise<Answers> {
|
||||
@ -460,14 +465,17 @@ async function askQuestionsForDeviceType(
|
||||
* [ 'network', 'wifiSsid', 'wifiKey', 'appUpdatePollInterval' ]
|
||||
*/
|
||||
function getQuestionNames(
|
||||
deviceType: BalenaSdk.DeviceType,
|
||||
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
|
||||
): Array<keyof Answers> {
|
||||
const questionNames: string[] = _.chain(deviceType.options)
|
||||
.flatMap(
|
||||
(group: BalenaSdk.DeviceTypeOptions) =>
|
||||
(group: BalenaSdk.DeviceTypeJson.DeviceTypeOptions) =>
|
||||
(group.isGroup && group.options) || [],
|
||||
)
|
||||
.map((groupOption: BalenaSdk.DeviceTypeOptionsGroup) => groupOption.name)
|
||||
.map(
|
||||
(groupOption: BalenaSdk.DeviceTypeJson.DeviceTypeOptionsGroup) =>
|
||||
groupOption.name,
|
||||
)
|
||||
.filter()
|
||||
.value();
|
||||
return questionNames as Array<keyof Answers>;
|
@ -46,11 +46,15 @@ export default class OsDownloadCmd extends Command {
|
||||
|
||||
You can pass \`--version menu\` to pick the OS version from the interactive menu
|
||||
of all available versions.
|
||||
|
||||
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 1.24.1',
|
||||
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0',
|
||||
'$ 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 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',
|
@ -42,6 +42,7 @@ export default class OsVersionsCmd extends Command {
|
||||
{
|
||||
name: 'type',
|
||||
description: 'device type',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
530
lib/commands/preload.ts
Normal file
530
lib/commands/preload.ts
Normal file
@ -0,0 +1,530 @@
|
||||
/**
|
||||
* @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 {
|
||||
getBalenaSdk,
|
||||
getCliForm,
|
||||
getVisuals,
|
||||
stripIndent,
|
||||
} from '../utils/lazy';
|
||||
import type { DockerConnectionCliFlags } from '../utils/docker';
|
||||
import { dockerConnectionCliFlags } from '../utils/docker';
|
||||
import * as _ from 'lodash';
|
||||
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 {
|
||||
app?: string;
|
||||
commit?: string;
|
||||
'splash-image'?: string;
|
||||
'dont-check-arch': boolean;
|
||||
'pin-device-to-release': boolean;
|
||||
'add-certificate'?: string[];
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
image: string;
|
||||
}
|
||||
|
||||
export default class PreloadCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Preload an app on a disk image (or Edison zip archive).
|
||||
|
||||
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 application, as it was
|
||||
preloaded.
|
||||
|
||||
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 --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png',
|
||||
'$ balena preload balena.img',
|
||||
];
|
||||
|
||||
public static args = [
|
||||
{
|
||||
name: 'image',
|
||||
description: 'the image file path',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'preload <image>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
app: flags.string({
|
||||
description: 'name of the application to preload',
|
||||
char: 'a',
|
||||
}),
|
||||
commit: flags.string({
|
||||
description: `\
|
||||
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',
|
||||
}),
|
||||
'splash-image': flags.string({
|
||||
description: 'path to a png image to replace the splash screen',
|
||||
char: 's',
|
||||
}),
|
||||
'dont-check-arch': flags.boolean({
|
||||
description:
|
||||
'disables check for matching architecture in image and application',
|
||||
}),
|
||||
'pin-device-to-release': flags.boolean({
|
||||
description:
|
||||
'pin the preloaded device to the preloaded release on provision',
|
||||
char: 'p',
|
||||
}),
|
||||
'add-certificate': flags.string({
|
||||
description: `\
|
||||
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
|
||||
The file name must end with '.crt' and must not be already contained in the preloader's
|
||||
/etc/ssl/certs folder.
|
||||
Can be repeated to add multiple certificates.\
|
||||
`,
|
||||
multiple: true,
|
||||
}),
|
||||
...dockerConnectionCliFlags,
|
||||
// Redefining --dockerPort here (defined already in dockerConnectionCliFlags)
|
||||
// without -p alias, to avoid clash with -p alias of pin-device-to-release
|
||||
dockerPort: flags.integer({
|
||||
description:
|
||||
'Docker daemon TCP port number (hint: 2375 for balena devices)',
|
||||
parse: (p) => parseAsInteger(p, 'dockerPort'),
|
||||
}),
|
||||
// Not supporting -h for help, because of clash with -h in DockerCliFlags
|
||||
// Revisit this in future release.
|
||||
help: flags.help({}),
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public static primary = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||
PreloadCmd,
|
||||
);
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
const balenaPreload = await import('balena-preload');
|
||||
const visuals = getVisuals();
|
||||
const nodeCleanup = await import('node-cleanup');
|
||||
const { instanceOf } = await import('../errors');
|
||||
|
||||
// Check image file exists
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
await fs.promises.access(params.image);
|
||||
} catch (error) {
|
||||
throw new ExpectedError(
|
||||
`The provided image path does not exist: ${params.image}`,
|
||||
);
|
||||
}
|
||||
|
||||
const progressBars: {
|
||||
[key: string]: ReturnType<typeof getVisuals>['Progress'];
|
||||
} = {};
|
||||
|
||||
const progressHandler = function (event: {
|
||||
name: string;
|
||||
percentage: number;
|
||||
}) {
|
||||
const progressBar = (progressBars[event.name] ??= new visuals.Progress(
|
||||
event.name,
|
||||
));
|
||||
return progressBar.update({ percentage: event.percentage });
|
||||
};
|
||||
|
||||
const spinners: {
|
||||
[key: string]: ReturnType<typeof getVisuals>['Spinner'];
|
||||
} = {};
|
||||
|
||||
const spinnerHandler = function (event: { name: string; action: string }) {
|
||||
const spinner = (spinners[event.name] ??= new visuals.Spinner(
|
||||
event.name,
|
||||
));
|
||||
if (event.action === 'start') {
|
||||
return spinner.start();
|
||||
} else {
|
||||
console.log();
|
||||
return spinner.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const commit = this.isCurrentCommit(options.commit || '')
|
||||
? 'latest'
|
||||
: options.commit;
|
||||
const image = params.image;
|
||||
const appId = options.app;
|
||||
|
||||
const splashImage = options['splash-image'];
|
||||
|
||||
const dontCheckArch = options['dont-check-arch'] || false;
|
||||
const pinDevice = options['pin-device-to-release'] || false;
|
||||
|
||||
if (dontCheckArch && !appId) {
|
||||
throw new ExpectedError(
|
||||
'You need to specify an app id if you disable the architecture check.',
|
||||
);
|
||||
}
|
||||
|
||||
const certificates: string[] = options['add-certificate'] || [];
|
||||
for (const certificate of certificates) {
|
||||
if (!certificate.endsWith('.crt')) {
|
||||
throw new ExpectedError('Certificate file name must end with ".crt"');
|
||||
}
|
||||
}
|
||||
|
||||
// Get a configured dockerode instance
|
||||
const dockerUtils = await import('../utils/docker');
|
||||
const docker = await dockerUtils.getDocker(options);
|
||||
const preloader = new balenaPreload.Preloader(
|
||||
null,
|
||||
docker,
|
||||
appId,
|
||||
commit,
|
||||
image,
|
||||
splashImage,
|
||||
undefined, // TODO: Currently always undefined, investigate approach in ssh command.
|
||||
dontCheckArch,
|
||||
pinDevice,
|
||||
certificates,
|
||||
);
|
||||
|
||||
let gotSignal = false;
|
||||
|
||||
nodeCleanup(function (_exitCode, signal) {
|
||||
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);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
preloader.stderr.pipe(process.stderr);
|
||||
}
|
||||
|
||||
preloader.on('progress', progressHandler);
|
||||
preloader.on('spinner', spinnerHandler);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
preloader.on('error', reject);
|
||||
resolve(
|
||||
this.prepareAndPreload(preloader, balena, {
|
||||
appId,
|
||||
commit,
|
||||
pinDevice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
if (instanceOf(err, balena.errors.BalenaError)) {
|
||||
const code = err.code ? `(${err.code})` : '';
|
||||
throw new ExpectedError(`${err.message} ${code}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!gotSignal) {
|
||||
await preloader.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly applicationExpandOptions: PineExpand<Application> = {
|
||||
owns__release: {
|
||||
$select: ['id', 'commit', 'end_timestamp', 'composition'],
|
||||
$orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }],
|
||||
$expand: {
|
||||
contains__image: {
|
||||
$select: ['image'],
|
||||
$expand: {
|
||||
image: {
|
||||
$select: ['image_size', 'is_stored_at__image_location'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
$filter: {
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
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();
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
$expand: this.applicationExpandOptions,
|
||||
$select: ['id', 'app_name', 'should_track_latest_release'],
|
||||
$orderby: 'app_name asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async selectApplication(deviceTypeSlug: string) {
|
||||
const visuals = getVisuals();
|
||||
|
||||
const applicationInfoSpinner = new visuals.Spinner(
|
||||
'Downloading list of applications and releases.',
|
||||
);
|
||||
applicationInfoSpinner.start();
|
||||
|
||||
const applications = await this.getApplicationsWithSuccessfulBuilds(
|
||||
deviceTypeSlug,
|
||||
);
|
||||
applicationInfoSpinner.stop();
|
||||
if (applications.length === 0) {
|
||||
throw new ExpectedError(
|
||||
`You have no apps with successful releases for a '${deviceTypeSlug}' device type.`,
|
||||
);
|
||||
}
|
||||
return getCliForm().ask({
|
||||
message: 'Select an application',
|
||||
type: 'list',
|
||||
choices: applications.map((app) => ({
|
||||
name: app.app_name,
|
||||
value: app,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
selectApplicationCommit(releases: Release[]) {
|
||||
if (releases.length === 0) {
|
||||
throw new ExpectedError('This application has no successful releases.');
|
||||
}
|
||||
const DEFAULT_CHOICE = { name: 'current', value: 'current' };
|
||||
const choices = [DEFAULT_CHOICE].concat(
|
||||
releases.map((release) => ({
|
||||
name: `${release.end_timestamp} - ${release.commit}`,
|
||||
value: release.commit,
|
||||
})),
|
||||
);
|
||||
return getCliForm().ask({
|
||||
message: 'Select a release',
|
||||
type: 'list',
|
||||
default: 'current',
|
||||
choices,
|
||||
});
|
||||
}
|
||||
|
||||
async offerToDisableAutomaticUpdates(
|
||||
application: Application,
|
||||
commit: string,
|
||||
pinDevice: boolean,
|
||||
) {
|
||||
const balena = getBalenaSdk();
|
||||
|
||||
if (
|
||||
this.isCurrentCommit(commit) ||
|
||||
!application.should_track_latest_release ||
|
||||
pinDevice
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = `\
|
||||
|
||||
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
|
||||
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 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 application now?\
|
||||
`;
|
||||
const update = await getCliForm().ask({
|
||||
message,
|
||||
type: 'confirm',
|
||||
});
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
return await balena.pine.patch({
|
||||
resource: 'application',
|
||||
id: application.id,
|
||||
body: {
|
||||
should_track_latest_release: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAppWithReleases(balenaSdk: BalenaSDK, appId: string | number) {
|
||||
const { getApplication } = await import('../utils/sdk');
|
||||
|
||||
return (await getApplication(balenaSdk, appId, {
|
||||
$expand: this.applicationExpandOptions,
|
||||
})) as Application & { should_be_running__release: [Release?] };
|
||||
}
|
||||
|
||||
async prepareAndPreload(
|
||||
preloader: Preloader,
|
||||
balenaSdk: BalenaSDK,
|
||||
options: {
|
||||
appId?: string;
|
||||
commit?: string;
|
||||
pinDevice: boolean;
|
||||
},
|
||||
) {
|
||||
await preloader.prepare();
|
||||
|
||||
const application = options.appId
|
||||
? await this.getAppWithReleases(balenaSdk, options.appId)
|
||||
: await this.selectApplication(preloader.config.deviceType);
|
||||
|
||||
let commit: string; // commit hash or the strings 'latest' or 'current'
|
||||
|
||||
const appCommit = application.should_be_running__release[0]?.commit;
|
||||
|
||||
// Use the commit given as --commit or show an interactive commit selection menu
|
||||
if (options.commit) {
|
||||
if (this.isCurrentCommit(options.commit)) {
|
||||
if (!appCommit) {
|
||||
throw new Error(
|
||||
`Unexpected empty commit hash for app ID "${application.id}"`,
|
||||
);
|
||||
}
|
||||
// handle `--commit current` (and its `--commit latest` synonym)
|
||||
commit = 'latest';
|
||||
} else {
|
||||
const release = _.find(application.owns__release, (r) =>
|
||||
r.commit.startsWith(options.commit!),
|
||||
);
|
||||
if (!release) {
|
||||
throw new ExpectedError(
|
||||
`There is no release matching commit "${options.commit}"`,
|
||||
);
|
||||
}
|
||||
commit = release.commit;
|
||||
}
|
||||
} else {
|
||||
// this could have the value 'current'
|
||||
commit = await this.selectApplicationCommit(
|
||||
application.owns__release as Release[],
|
||||
);
|
||||
}
|
||||
|
||||
await preloader.setAppIdAndCommit(
|
||||
application.id,
|
||||
this.isCurrentCommit(commit) ? appCommit! : commit,
|
||||
);
|
||||
|
||||
// Propose to disable automatic app updates if the commit is not the current release
|
||||
await this.offerToDisableAutomaticUpdates(
|
||||
application,
|
||||
commit,
|
||||
options.pinDevice,
|
||||
);
|
||||
|
||||
// All options are ready: preload the image.
|
||||
await preloader.preload();
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ 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';
|
||||
|
||||
enum BuildTarget {
|
||||
@ -33,6 +33,7 @@ interface FlagsDef {
|
||||
emulated: boolean;
|
||||
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
|
||||
nocache?: boolean;
|
||||
pull?: boolean;
|
||||
'noparent-check'?: boolean;
|
||||
'registry-secrets'?: string;
|
||||
gitignore?: boolean;
|
||||
@ -54,10 +55,9 @@ interface ArgsDef {
|
||||
|
||||
export default class PushCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Start a remote build on the balena cloud build servers or a local mode device.
|
||||
Start a remote build on the balenaCloud build servers or a local mode device.
|
||||
|
||||
start a build on the remote balena cloud builders,
|
||||
or a local mode balena device.
|
||||
Start a build on the remote balenaCloud builders, or a local mode balena 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
|
||||
@ -84,8 +84,8 @@ export default class PushCmd extends Command {
|
||||
|
||||
${dockerignoreHelp.split('\n').join('\n\t\t')}
|
||||
|
||||
Note: --service and --env flags must come after the applicationOrDevice parameter,
|
||||
as per examples.
|
||||
Note: the --service and --env flags must come after the applicationOrDevice
|
||||
parameter, as per examples.
|
||||
`;
|
||||
|
||||
public static examples = [
|
||||
@ -115,13 +115,16 @@ export default class PushCmd extends Command {
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
source: flags.string({
|
||||
description:
|
||||
'Source directory to be sent to balenaCloud or balenaOS device (default: current working dir)',
|
||||
description: stripIndent`
|
||||
Source directory to be sent to balenaCloud or balenaOS device
|
||||
(default: current working dir)`,
|
||||
char: 's',
|
||||
}),
|
||||
emulated: flags.boolean({
|
||||
description: 'Force an emulated build to occur on the remote builder',
|
||||
char: 'f',
|
||||
description: stripIndent`
|
||||
Don't use native ARM servers; force QEMU ARM emulation on Intel x86-64
|
||||
servers during the image build (balenaCloud).`,
|
||||
char: 'e',
|
||||
}),
|
||||
dockerfile: flags.string({
|
||||
description:
|
||||
@ -129,38 +132,44 @@ export default class PushCmd extends Command {
|
||||
}),
|
||||
nocache: flags.boolean({
|
||||
description: stripIndent`
|
||||
Don't use cached layers of previously built images for this project. This ensures
|
||||
that the latest base image and packages are pulled. Note that build logs may still
|
||||
display the message _"Pulling previous images for caching purposes" (as the cloud
|
||||
builder needs previous images to compute delta updates), but the logs will not
|
||||
display the "Using cache" lines for each build step of a Dockerfile.`,
|
||||
|
||||
Don't use cached layers of previously built images for this project. This
|
||||
ensures that the latest base image and packages are pulled. Note that build
|
||||
logs may still display the message _"Pulling previous images for caching
|
||||
purposes" (as the cloud builder needs previous images to compute delta
|
||||
updates), but the logs will not display the "Using cache" lines for each
|
||||
build step of a Dockerfile.`,
|
||||
char: 'c',
|
||||
}),
|
||||
pull: flags.boolean({
|
||||
description: stripIndent`
|
||||
When pushing to a local device, force the base images to be pulled again.
|
||||
Currently this option is ignored when pushing to the balenaCloud builders.`,
|
||||
}),
|
||||
'noparent-check': flags.boolean({
|
||||
description: `Disable project validation check of 'docker-compose.yml' file in parent folder`,
|
||||
description: stripIndent`
|
||||
Disable project validation check of 'docker-compose.yml' file in parent folder`,
|
||||
}),
|
||||
'registry-secrets': flags.string({
|
||||
description: stripIndent`
|
||||
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images.
|
||||
Note that if registry-secrets are not provided on the command line, a secrets configuration
|
||||
file from the balena directory will be used (usually $HOME/.balena/secrets.yml|.json)`,
|
||||
Path to a local YAML or JSON file containing Docker registry passwords used
|
||||
to pull base images. Note that if registry-secrets are not provided on the
|
||||
command line, a secrets configuration file from the balena directory will be
|
||||
used (usually $HOME/.balena/secrets.yml|.json)`,
|
||||
char: 'R',
|
||||
}),
|
||||
nolive: flags.boolean({
|
||||
description: stripIndent`
|
||||
Don't run a live session on this push. The filesystem will not be monitored, and changes
|
||||
will not be synchronized to any running containers. Note that both this flag and --detached
|
||||
and required to cause the process to end once the initial build has completed.`,
|
||||
Don't run a live session on this push. The filesystem will not be monitored,
|
||||
and changes will not be synchronized to any running containers. Note that both
|
||||
this flag and --detached and required to cause the process to end once the
|
||||
initial build has completed.`,
|
||||
}),
|
||||
detached: flags.boolean({
|
||||
description: stripIndent`
|
||||
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 application logs when the build
|
||||
has completed.`,
|
||||
|
||||
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 application logs when the build has completed.`,
|
||||
char: 'd',
|
||||
}),
|
||||
service: flags.string({
|
||||
@ -210,8 +219,8 @@ export default class PushCmd extends Command {
|
||||
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.`,
|
||||
to the CLI v11 behavior/implementation (deprecated) if compatibility is
|
||||
required until your project can be adapted.`,
|
||||
char: 'g',
|
||||
exclusive: ['multi-dockerignore'],
|
||||
}),
|
||||
@ -308,6 +317,7 @@ export default class PushCmd extends Command {
|
||||
registrySecrets,
|
||||
multiDockerignore: options['multi-dockerignore'] || false,
|
||||
nocache: options.nocache || false,
|
||||
pull: options.pull || false,
|
||||
nogitignore,
|
||||
noParentCheck: options['noparent-check'] || false,
|
||||
nolive: options.nolive || false,
|
||||
@ -328,15 +338,9 @@ export default class PushCmd extends Command {
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ExpectedError(
|
||||
stripIndent`
|
||||
Build target not recognized. Please provide either an application name or device address.
|
||||
|
||||
The only supported device addresses currently are IP addresses.
|
||||
|
||||
If you believe your build target should have been detected, and this is an error, please
|
||||
create an issue.`,
|
||||
);
|
||||
throw new ExpectedError(stripIndent`
|
||||
Build target not recognized. Please provide either an application name or
|
||||
device IP address.`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,17 +366,21 @@ export default class PushCmd extends Command {
|
||||
async getAppOwner(sdk: BalenaSDK, appName: string) {
|
||||
const _ = await import('lodash');
|
||||
|
||||
const applications = await sdk.models.application.getAll({
|
||||
const applications = (await sdk.models.application.getAll({
|
||||
$expand: {
|
||||
user: {
|
||||
$select: ['username'],
|
||||
organization: {
|
||||
$select: ['handle'],
|
||||
},
|
||||
},
|
||||
$filter: {
|
||||
$eq: [{ $tolower: { $: 'app_name' } }, appName.toLowerCase()],
|
||||
},
|
||||
$select: ['id'],
|
||||
});
|
||||
})) as Array<
|
||||
Application & {
|
||||
organization: [Organization];
|
||||
}
|
||||
>;
|
||||
|
||||
if (applications == null || applications.length === 0) {
|
||||
throw new ExpectedError(
|
||||
@ -385,7 +393,7 @@ export default class PushCmd extends Command {
|
||||
}
|
||||
|
||||
if (applications.length === 1) {
|
||||
return _.get(applications, '[0].user[0].username');
|
||||
return applications[0].organization[0].handle;
|
||||
}
|
||||
|
||||
// If we got more than one application with the same name it means that the
|
||||
@ -393,7 +401,7 @@ export default class PushCmd extends Command {
|
||||
// present a list to the user which shows the fully qualified application
|
||||
// name (user/appname) and allows them to select
|
||||
const entries = _.map(applications, (app) => {
|
||||
const username = _.get(app, 'user[0].username');
|
||||
const username = app.organization[0].handle;
|
||||
return {
|
||||
name: `${username}/${appName}`,
|
||||
extra: username,
|
@ -19,9 +19,10 @@ import { flags } from '@oclif/command';
|
||||
import type { LocalBalenaOsDevice } from 'balena-sync';
|
||||
import Command from '../command';
|
||||
import * as cf from '../utils/common-flags';
|
||||
import { getVisuals, stripIndent } from '../utils/lazy';
|
||||
import { getCliUx, stripIndent } from '../utils/lazy';
|
||||
|
||||
interface FlagsDef {
|
||||
json?: boolean;
|
||||
verbose: boolean;
|
||||
timeout?: number;
|
||||
help: void;
|
||||
@ -32,6 +33,11 @@ export default class ScanCmd extends Command {
|
||||
Scan for balenaOS devices on your local network.
|
||||
|
||||
Scan for balenaOS devices on your local network.
|
||||
|
||||
The output includes device information collected through balenaEngine for
|
||||
devices running a development image of balenaOS. Devices running a production
|
||||
image do not expose balenaEngine (on TCP port 2375), which is why less
|
||||
information is printed about them.
|
||||
`;
|
||||
|
||||
public static examples = [
|
||||
@ -53,18 +59,19 @@ export default class ScanCmd extends Command {
|
||||
description: 'scan timeout in seconds',
|
||||
}),
|
||||
help: cf.help,
|
||||
json: flags.boolean({
|
||||
char: 'j',
|
||||
description: 'produce JSON output instead of tabular output',
|
||||
}),
|
||||
};
|
||||
|
||||
public static primary = true;
|
||||
public static root = true;
|
||||
|
||||
public async run() {
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const { SpinnerPromise } = getVisuals();
|
||||
const { discover } = await import('balena-sync');
|
||||
const prettyjson = await import('prettyjson');
|
||||
const { ExpectedError } = await import('../errors');
|
||||
const dockerUtils = await import('../utils/docker');
|
||||
|
||||
const dockerPort = 2375;
|
||||
@ -76,55 +83,75 @@ export default class ScanCmd extends Command {
|
||||
options.timeout != null ? options.timeout * 1000 : undefined;
|
||||
|
||||
// Find active local devices
|
||||
const activeLocalDevices: LocalBalenaOsDevice[] = await new SpinnerPromise({
|
||||
promise: discover.discoverLocalBalenaOsDevices(discoverTimeout),
|
||||
startMessage: 'Scanning for local balenaOS devices..',
|
||||
stopMessage: 'Reporting scan results',
|
||||
}).filter(async ({ address }: { address: string }) => {
|
||||
const docker = dockerUtils.createClient({
|
||||
host: address,
|
||||
port: dockerPort,
|
||||
timeout: dockerTimeout,
|
||||
}) as any;
|
||||
try {
|
||||
await docker.pingAsync();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const ux = getCliUx();
|
||||
ux.action.start('Scanning for local balenaOS devices');
|
||||
|
||||
// Exit with message if no devices found
|
||||
if (_.isEmpty(activeLocalDevices)) {
|
||||
// TODO: Consider whether this should really be an error
|
||||
throw new ExpectedError(
|
||||
process.platform === 'win32'
|
||||
? ScanCmd.noDevicesFoundMessage + ScanCmd.windowsTipMessage
|
||||
: ScanCmd.noDevicesFoundMessage,
|
||||
);
|
||||
}
|
||||
|
||||
// Query devices for info
|
||||
const devicesInfo = await Promise.all(
|
||||
activeLocalDevices.map(({ host, address }) => {
|
||||
const localDevices: LocalBalenaOsDevice[] = await discover.discoverLocalBalenaOsDevices(
|
||||
discoverTimeout,
|
||||
);
|
||||
const engineReachableDevices: boolean[] = await Promise.all(
|
||||
localDevices.map(async ({ address }: { address: string }) => {
|
||||
const docker = dockerUtils.createClient({
|
||||
host: address,
|
||||
port: dockerPort,
|
||||
timeout: dockerTimeout,
|
||||
}) as any;
|
||||
return Bluebird.props({
|
||||
host,
|
||||
address,
|
||||
dockerInfo: docker
|
||||
.infoAsync()
|
||||
.catchReturn('Could not get Docker info'),
|
||||
dockerVersion: docker
|
||||
.versionAsync()
|
||||
.catchReturn('Could not get Docker version'),
|
||||
});
|
||||
try {
|
||||
await docker.pingAsync();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const developmentDevices: LocalBalenaOsDevice[] = localDevices.filter(
|
||||
(_localDevice, index) => engineReachableDevices[index],
|
||||
);
|
||||
|
||||
const productionDevices = _.differenceWith(
|
||||
localDevices,
|
||||
developmentDevices,
|
||||
_.isEqual,
|
||||
);
|
||||
|
||||
const productionDevicesInfo = _.map(
|
||||
productionDevices,
|
||||
(device: LocalBalenaOsDevice) => {
|
||||
return {
|
||||
host: device.host,
|
||||
address: device.address,
|
||||
osVariant: 'production',
|
||||
dockerInfo: undefined,
|
||||
dockerVersion: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Query devices for info
|
||||
const devicesInfo = await Promise.all(
|
||||
developmentDevices.map(async ({ host, address }) => {
|
||||
const docker = dockerUtils.createClient({
|
||||
host: address,
|
||||
port: dockerPort,
|
||||
timeout: dockerTimeout,
|
||||
}) as any;
|
||||
const [dockerInfo, dockerVersion] = await Promise.all([
|
||||
docker.infoAsync().catchReturn('Could not get Docker info'),
|
||||
docker.versionAsync().catchReturn('Could not get Docker version'),
|
||||
]);
|
||||
return {
|
||||
host,
|
||||
address,
|
||||
osVariant: 'development',
|
||||
dockerInfo,
|
||||
dockerVersion,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
ux.action.stop('Reporting scan results');
|
||||
|
||||
// Reduce properties if not --verbose
|
||||
if (!options.verbose) {
|
||||
devicesInfo.forEach((d: any) => {
|
||||
@ -137,8 +164,22 @@ export default class ScanCmd extends Command {
|
||||
});
|
||||
}
|
||||
|
||||
const cmdOutput = productionDevicesInfo.concat(devicesInfo);
|
||||
|
||||
// Output results
|
||||
console.log(prettyjson.render(devicesInfo, { noColor: true }));
|
||||
if (!options.json && cmdOutput.length === 0) {
|
||||
console.error(
|
||||
process.platform === 'win32'
|
||||
? ScanCmd.noDevicesFoundMessage + ScanCmd.windowsTipMessage
|
||||
: ScanCmd.noDevicesFoundMessage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
options.json
|
||||
? JSON.stringify(cmdOutput, null, 4)
|
||||
: prettyjson.render(cmdOutput, { noColor: true }),
|
||||
);
|
||||
}
|
||||
|
||||
protected static dockerInfoProperties = [
|
@ -28,7 +28,7 @@ export default class SettingsCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Print current settings.
|
||||
|
||||
Use this command to display current balena CLI settings.
|
||||
Use this command to display the current balena CLI settings.
|
||||
`;
|
||||
public static examples = ['$ balena settings'];
|
||||
|
@ -36,7 +36,7 @@ interface FlagsDef {
|
||||
|
||||
interface ArgsDef {
|
||||
applicationOrDevice: string;
|
||||
serviceName?: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export default class NoteCmd extends Command {
|
||||
@ -85,13 +85,13 @@ export default class NoteCmd extends Command {
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'serviceName',
|
||||
name: 'service',
|
||||
description: 'service name, if connecting to a container',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'ssh <applicationOrDevice> [serviceName]';
|
||||
public static usage = 'ssh <applicationOrDevice> [service]';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
port: flags.integer({
|
||||
@ -134,7 +134,7 @@ export default class NoteCmd extends Command {
|
||||
port: options.port,
|
||||
forceTTY: options.tty,
|
||||
verbose: options.verbose,
|
||||
service: params.serviceName,
|
||||
service: params.service,
|
||||
});
|
||||
}
|
||||
|
||||
@ -214,11 +214,11 @@ export default class NoteCmd extends Command {
|
||||
// At this point, we have a long uuid with a device
|
||||
// that we know exists and is accessible
|
||||
let containerId: string | undefined;
|
||||
if (params.serviceName != null) {
|
||||
if (params.service != null) {
|
||||
containerId = await this.getContainerId(
|
||||
sdk,
|
||||
uuid,
|
||||
params.serviceName,
|
||||
params.service,
|
||||
{
|
||||
port: options.port,
|
||||
proxyCommand,
|
173
lib/commands/support.ts
Normal file
173
lib/commands/support.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @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 { ExpectedError } from '../errors';
|
||||
import * as cf from '../utils/common-flags';
|
||||
import { getBalenaSdk, getCliUx, stripIndent } from '../utils/lazy';
|
||||
|
||||
interface FlagsDef {
|
||||
application?: string;
|
||||
device?: string;
|
||||
duration?: string;
|
||||
help: void;
|
||||
}
|
||||
|
||||
interface ArgsDef {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export default class SupportCmd extends Command {
|
||||
public static description = stripIndent`
|
||||
Grant or revoke support access for devices and applications.
|
||||
|
||||
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 --application flags accept multiple values, specified as
|
||||
a comma-separated list (with no spaces).
|
||||
`;
|
||||
|
||||
public static examples = [
|
||||
'balena support enable --device ab346f,cd457a --duration 3d',
|
||||
'balena support enable --application app3 --duration 12h',
|
||||
'balena support disable -a myApp',
|
||||
];
|
||||
|
||||
public static args = [
|
||||
{
|
||||
name: 'action',
|
||||
description: 'enable|disable support access',
|
||||
options: ['enable', 'disable'],
|
||||
},
|
||||
];
|
||||
|
||||
public static usage = 'support <action>';
|
||||
|
||||
public static flags: flags.Input<FlagsDef> = {
|
||||
device: flags.string({
|
||||
description: 'comma-separated list (no spaces) of device UUIDs',
|
||||
char: 'd',
|
||||
}),
|
||||
application: flags.string({
|
||||
description: 'comma-separated list (no spaces) of application names',
|
||||
char: 'a',
|
||||
}),
|
||||
duration: flags.string({
|
||||
description:
|
||||
'length of time to enable support for, in (h)ours or (d)ays, e.g. 12h, 2d',
|
||||
char: 't',
|
||||
}),
|
||||
help: cf.help,
|
||||
};
|
||||
|
||||
public static authenticated = true;
|
||||
|
||||
public async run() {
|
||||
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
|
||||
SupportCmd,
|
||||
);
|
||||
|
||||
const balena = getBalenaSdk();
|
||||
const ux = getCliUx();
|
||||
|
||||
const enabling = params.action === 'enable';
|
||||
|
||||
// Validation
|
||||
if (!options.device && !options.application) {
|
||||
throw new ExpectedError(
|
||||
'At least one device or application must be specified',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.duration != null && !enabling) {
|
||||
throw new ExpectedError(
|
||||
'--duration option is only applicable when enabling support',
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate expiry ts
|
||||
const durationDefault = '24h';
|
||||
const duration = options.duration || durationDefault;
|
||||
const expiryTs = Date.now() + this.parseDuration(duration);
|
||||
|
||||
const deviceUuids = options.device?.split(',') || [];
|
||||
const appNames = options.application?.split(',') || [];
|
||||
|
||||
const enablingMessage = 'Enabling support access for';
|
||||
const disablingMessage = 'Disabling support access for';
|
||||
|
||||
// Process devices
|
||||
for (const deviceUuid of deviceUuids) {
|
||||
if (enabling) {
|
||||
ux.action.start(`${enablingMessage} device ${deviceUuid}`);
|
||||
await balena.models.device.grantSupportAccess(deviceUuid, expiryTs);
|
||||
} else if (params.action === 'disable') {
|
||||
ux.action.start(`${disablingMessage} device ${deviceUuid}`);
|
||||
await balena.models.device.revokeSupportAccess(deviceUuid);
|
||||
}
|
||||
ux.action.stop();
|
||||
}
|
||||
|
||||
// Process applications
|
||||
for (const appName of appNames) {
|
||||
if (enabling) {
|
||||
ux.action.start(`${enablingMessage} application ${appName}`);
|
||||
await balena.models.application.grantSupportAccess(appName, expiryTs);
|
||||
} else if (params.action === 'disable') {
|
||||
ux.action.start(`${disablingMessage} application ${appName}`);
|
||||
await balena.models.application.revokeSupportAccess(appName);
|
||||
}
|
||||
ux.action.stop();
|
||||
}
|
||||
|
||||
if (enabling) {
|
||||
console.log(
|
||||
`Access has been granted for ${duration}, expiring ${new Date(
|
||||
expiryTs,
|
||||
).toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parseDuration(duration: string): number {
|
||||
const parseErrorMsg =
|
||||
'Duration must be specified as number followed by h or d, e.g. 24h, 1d';
|
||||
const unit = duration.slice(duration.length - 1);
|
||||
const amount = Number(duration.substring(0, duration.length - 1));
|
||||
|
||||
if (isNaN(amount)) {
|
||||
throw new ExpectedError(parseErrorMsg);
|
||||
}
|
||||
|
||||
let durationMs;
|
||||
if (['h', 'H'].includes(unit)) {
|
||||
durationMs = amount * 60 * 60 * 1000;
|
||||
} else if (['d', 'D'].includes(unit)) {
|
||||
durationMs = amount * 24 * 60 * 60 * 1000;
|
||||
} else {
|
||||
throw new ExpectedError(parseErrorMsg);
|
||||
}
|
||||
|
||||
return durationMs;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user