Compare commits

...

147 Commits

Author SHA1 Message Date
fdc2bff063 v12.42.0 2021-03-19 15:28:52 +02:00
4f6f20f469 Merge pull request #2218 from chriswiggins/public-address
Public address
2021-03-19 13:26:48 +00:00
50af0760ce balena device: Display public IP address field
Change-type: minor
2021-03-19 14:41:21 +13:00
43906d22c8 Update balena-sdk from 15.20.0 to 15.29.0
Change-type: patch
2021-03-19 14:41:20 +13:00
43f1188f1d v12.41.3 2021-03-17 18:45:08 +02:00
2629a01c7f Merge pull request #2223 from balena-io/engines-npm-v7
Update supported npm version range in package.json (<7.0.0)
2021-03-17 16:42:55 +00:00
5fc009a6ae Update supported npm version range in package.json (<7.0.0)
Connects-to: #2221
Change-type: patch
2021-03-17 15:28:39 +00:00
480f84993b v12.41.2 2021-03-17 10:46:32 +02:00
d1fdbd927e Merge pull request #2208 from balena-io/linux-install-docs-sudo
Linux installation instructions: Add sudo configuration section
2021-03-17 08:44:22 +00:00
4bfd345b68 v12.41.1 2021-03-15 20:39:53 +02:00
d4a153d2ee Merge pull request #2212 from balena-io/klutchell/balenalib-dockerfiles
docker: Fix path to init when workdir is changed
2021-03-15 18:38:01 +00:00
3cff091e3a docker: Fix path to init when workdir is changed
Change-type: patch
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-03-15 10:32:43 -04:00
b2ad9f1643 v12.41.0 2021-03-15 16:07:04 +02:00
f7623bef85 Merge pull request #2159 from balena-io/klutchell/balenalib-dockerfiles
dockerfiles: initial commit of balenalib dockerfiles
2021-03-15 14:04:52 +00:00
af63794571 docs: Add Docker to Advanced Installation instructions
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-03-15 08:34:34 -04:00
65d5bdff08 docker: Add Docker images with the CLI and Docker-in-Docker
Add Dockerfiles for alpine and debian images, based on
balenalib/arch-distro-node images.

Change-type: minor
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-03-15 08:34:23 -04:00
23165806aa v12.40.4 2021-03-09 19:36:10 +02:00
3649bafbb1 Merge pull request #2209 from balena-io/update-apple-certificate-name
macOS GUI installer: Update signing certificate name
2021-03-09 17:34:12 +00:00
c62445a399 macOS GUI installer: Update signing certificate name
Change-type: patch
2021-03-09 16:40:38 +00:00
b233ea3e3e Linux installation instructions: Add sudo configuration section
Change-type: patch
2021-03-07 23:31:31 +00:00
4fe660b3a5 v12.40.3 2021-03-06 18:51:02 +02:00
1f07cd1b1c Merge pull request #2207 from balena-io/fix-qemu-download-error-handling
build, deploy: Fix error handling when QEMU download fails
2021-03-06 16:48:59 +00:00
bcea5193a1 build, deploy: Fix error handling when QEMU download fails
Change-type: patch
2021-03-06 16:10:33 +00:00
8b99cd7170 v12.40.2 2021-02-24 00:45:01 +02:00
1986c9339c Merge pull request #2197 from balena-io/device-local-mode-markdown
docs: Fix missing markdown docs for device `deactivate` and `local-mode`
2021-02-23 22:43:00 +00:00
b90c9b0d7e docs: Fix missing markdown docs for device deactivate and local-mode
Change-type: patch
2021-02-23 22:10:52 +00:00
e28c3f9814 v12.40.1 2021-02-23 16:55:03 +02:00
d054ced541 Merge pull request #2194 from balena-io/klutchell/emulated-docs
docs: emphasize that push emulation is not required in most cases
2021-02-23 14:52:52 +00:00
c8e4d2c9a6 docs: emphasize that push emulation is not required in most cases
Change-type: patch
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-02-23 08:55:25 -05:00
9671372b9e v12.40.0 2021-02-10 03:28:52 +02:00
2a4ff75203 Merge pull request #2177 from balena-io/livepush-compose-dev-overlay
Add support for docker-compose dev overlay
2021-02-10 01:26:52 +00:00
f3d750a024 Add support for docker-compose dev overlay in local pushes
Change-type: minor
Signed-off-by: Scott Lowe <scott@balena.io>
2021-02-09 13:06:03 +01:00
a701cd8d4d v12.39.1 2021-02-07 01:08:07 +02:00
e2c0c2f359 Merge pull request #2179 from balena-io/klutchell/qemu-v5.2.0+balena4
build/deploy: fix emulated builds to use fully static qemu binaries
2021-02-06 23:05:50 +00:00
15fc805f89 build/deploy: fix emulated builds to use fully static qemu binaries
Avoid possible situations where the local glibc may not support
the required syscalls for arm emulation during build/deploy.

Change-type: patch
Conneted-to: https://github.com/balena-io/qemu/issues/21
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-02-06 09:50:46 -05:00
0a995ecc49 v12.39.0 2021-02-04 19:41:10 +02:00
1ba992ada2 Merge pull request #2176 from balena-io/device-localmode
Add command `device local-mode`
2021-02-04 17:39:05 +00:00
e47fd0c887 Add command device local-mode
Change-type: minor
Resolves: #1304
Signed-off-by: Scott Lowe <scott@balena.io>
2021-02-04 15:36:32 +00:00
af1de34840 v12.38.10 2021-02-04 17:00:43 +02:00
96fb525378 Merge pull request #2169 from balena-io/deduplicated-msg
Improve build-time checks (automation/test-lock-deduplicated.sh)
2021-02-04 14:58:27 +00:00
3d1f16c0ab v12.38.9 2021-02-04 15:59:06 +02:00
6fb58a25fc Merge pull request #2175 from balena-io/cloud-build-orgs
Modify push to pass app slug to builder
2021-02-04 13:56:31 +00:00
e6b85c9cf8 Modify push to pass app slug to builder
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2021-02-04 10:41:00 +01:00
43b93e7fd4 v12.38.8 2021-01-29 17:04:38 +02:00
a05dcf08b8 Merge pull request #2173 from balena-io/klutchell/qemu-v5.2.0
build/deploy: Update QEMU to speed up emulated builds
2021-01-29 15:02:49 +00:00
9636985ee7 build/deploy: Update QEMU to speed up emulated builds
QEMU v5 has quite a few improvements over v4, and the speed
difference when emulating arm is quite noticible.

We tested this with, and without, our single-core limitation
patch and have not been able to reproduce the stability
issues we were seeing in v4 so the patch was removed in
this release.

Change-type: patch
Connects-to: https://github.com/balena-io/balena-io/issues/2340
Signed-off-by: Kyle Harding <kyle@balena.io>
2021-01-29 09:26:34 -05:00
023fc57914 v12.38.7 2021-01-26 10:31:28 +02:00
492bdab2fe Merge pull request #2170 from balena-io/tunnel-help-openbalena
tunnel: Add note re openBalena version compatibility
2021-01-26 08:29:29 +00:00
941c365259 tunnel: Add note re openBalena version compatibility
Change-type: patch
2021-01-25 17:33:41 +00:00
fed58278c9 v12.38.6 2021-01-23 03:09:50 +02:00
d74af38bfe Merge pull request #2171 from balena-io/debug-logging
logging: note that the device supervisor version is operative
2021-01-23 01:07:30 +00:00
53926067ca logging: note that the device supervisor version is operative
Change-type: patch
Signed-off-by: Matthew McGinn <matthew@balena.io>
2021-01-22 16:53:15 -05:00
7181dc5401 v12.38.5 2021-01-22 12:59:32 +02:00
e35e13f9a7 Merge pull request #2163 from balena-io/switch-tunnel-to-tls
tls: Use TLS for tunnel connection
2021-01-22 10:57:39 +00:00
6e0638f3be Improve build-time checks (automation/test-lock-deduplicated.sh)
Change-type: patch
2021-01-21 21:29:10 +00:00
d60ec13d5c v12.38.4 2021-01-21 19:14:47 +02:00
731e50a757 Merge pull request #2166 from balena-io/engines-less-than-13
Update supported Node.js version range in package.json (<13.0.0)
2021-01-21 17:11:55 +00:00
b363d28664 Update supported Node.js version range in package.json (<13.0.0)
Change-type: patch
2021-01-21 15:58:08 +00:00
7ae83d9ce5 tls: Use TLS for tunnel connection
Switch to using the exposed tunnelUrl and TLS for making
tunnels to the device, to improve security.

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
2021-01-20 21:18:23 +00:00
31281549a6 v12.38.3 2021-01-19 19:19:10 +02:00
e86bcc438c Merge pull request #2161 from balena-io/workaround-push-public
Handle 'push' edge case with application access
2021-01-19 17:17:31 +00:00
a1cf602f6f Handle 'push' edge case with application access
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2021-01-19 13:38:22 +01:00
4cd3ef8b91 v12.38.2 2021-01-19 10:22:20 +02:00
e4eb4586f5 Merge pull request #2158 from balena-io/delete-travis-appveyor
Delete old config files for Travis and AppVeyor to avoid confusion
2021-01-19 08:20:37 +00:00
360c6e42f8 v12.38.1 2021-01-15 18:54:21 +02:00
f76702c4e0 Merge pull request #2160 from balena-io/fix-errorhandler-strings
Fix handling of thrown strings
2021-01-15 16:52:14 +00:00
d3586696b4 Fix handling of thrown strings
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2021-01-15 16:45:01 +01:00
f73e3db4de Delete old config files for Travis and AppVeyor to avoid confusion
Change-type: patch
2021-01-15 14:05:16 +00:00
1f74889386 v12.38.0 2021-01-15 01:27:04 +02:00
743de66138 Merge pull request #2154 from balena-io/add_release_tags_to_deploy
Add release-tag on deploy command
2021-01-14 23:24:51 +00:00
8d56fe9678 deploy: Add --release-tag flag
Now we can do:
`balena deploy myApp myApp/myImage --release-tag key1 value1`

Refactor and reuse the logic that parses and applies the
release tag options from the push command to the deploy
command.

Resolves: #892
Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2021-01-15 00:46:39 +02:00
3d9d8bf5c8 v12.37.2 2021-01-14 16:50:48 +02:00
8c3df9ae30 Merge pull request #2153 from balena-io/america
docs: americanize the spelling of words in sourced markdown
2021-01-14 14:48:52 +00:00
e71184ed3a docs: americanize the spelling of words in sourced markdown
Change-type: patch
Signed-off-by: Matthew McGinn <matthew@balena.io>
2021-01-13 10:12:24 -05:00
caadce6c2b v12.37.1 2021-01-06 17:30:57 +02:00
f45fac6138 Merge pull request #2148 from balena-io/remove-internal-scandevices
Refactor out command internal scandevices
2021-01-06 15:29:10 +00:00
aeff5997d0 Refactor out command internal scandevices
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2021-01-06 15:00:18 +01:00
b5028c65cc v12.37.0 2020-12-29 12:58:58 +02:00
f69276e7c9 Merge pull request #2146 from balena-io/update-preload-10.4.1
Update preload 10.4.1
2020-12-29 10:56:43 +00:00
9fff9266d4 Add --additional-space flag to preload
Change-type: minor
2020-12-28 17:08:20 +01:00
0e7f953f72 Update balena-preload to 10.4.1
10.4.0 improves image size estimation
10.4.1 prevents running out of space while pulling images because of temporary files

Change-type: patch
2020-12-28 16:42:12 +01:00
61b11994b5 v12.36.1 2020-12-24 02:20:29 +02:00
1e1935cfb1 Merge pull request #2144 from balena-io/orgs-push
Update push command for organizations
2020-12-24 00:18:38 +00:00
27e2b03702 Update push command for organizations
Change-type: patch
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-23 16:03:52 +01:00
358acbd2c8 v12.36.0 2020-12-23 09:36:42 +02:00
b040a21268 Merge pull request #2138 from balena-io/add_tag_on_push
push: Add --release-tag flag
2020-12-23 07:35:03 +00:00
074fe010bd errors: Make all exclusive flag errors expected
eg Don't report errors if during a push --release-tag
and --detached flags are used.

Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-22 17:10:10 +02:00
34557e35ee push: Add --release-tag flag
You can have 0 or multiple keys without values,
if you use values then you should have as many
values as you have keys. If you don't want to set
a value for a key set its value to "" (bash, cmd.exe)
or '""' (powershell).

Connects-to: #892
Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-22 17:10:10 +02:00
3bff569758 v12.35.3 2020-12-21 13:18:38 +02:00
cf06a8dfad Merge pull request #2143 from balena-io/improve-id-disambiguation-tags
Improve id disambiguation for tag commands
2020-12-21 11:16:55 +00:00
584aa745f7 Improve id disambiguation for tag commands
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-18 12:52:18 +01:00
194d12cb3d v12.35.2 2020-12-18 11:44:48 +02:00
7739379444 Merge pull request #2137 from balena-io/fix-balenadev-sigterm
Modify handling of SIGINT in balena-dev
2020-12-18 09:42:03 +00:00
5c93df921e Modify handling of SIGINT in balena-dev
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-18 10:19:55 +01:00
da652c6bce v12.35.1 2020-12-17 17:06:38 +02:00
1cd341e6cd Merge pull request #2139 from balena-io/org-support-ssh-tunnel
Update commands ssh, tunnel to support orgs
2020-12-17 15:04:34 +00:00
9d2884aab7 Update commands ssh, tunnel to support orgs
Change-type: patch
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-17 13:01:53 +01:00
f128eaf389 v12.35.0 2020-12-15 17:46:38 +02:00
70b0524eb6 Merge pull request #2131 from balena-io/update-app-command-info-for-orgs
Update various commands to support organizations
2020-12-15 15:44:12 +00:00
c898747468 Update various commands to support organizations
Change-type: minor
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-15 16:06:25 +01:00
6fc3b0df58 v12.34.0 2020-12-15 16:47:17 +02:00
746676beb9 Merge pull request #2127 from balena-io/app-create-orgs
Add organizations support to app create command
2020-12-15 14:42:38 +00:00
611f59a0da Add organizations support to app create command
Change-type: minor
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-15 14:58:17 +01:00
c6430274e5 v12.33.2 2020-12-15 09:40:26 +02:00
9637f75617 Merge pull request #2050 from josecoelho/1667-permission-validation
Improve error message to access balena settings
2020-12-15 07:37:57 +00:00
439d8391ee Improve error message for issues to access balena settings
Update balena-settings-storage from 6.0.1 to 7.0.0

Resolves: #1667
Change-type: patch
2020-12-15 20:14:54 +13:00
0d3ca63f00 v12.33.1 2020-12-11 18:44:05 +02:00
1f3677bdb2 Merge pull request #2136 from balena-io/fix-preload-app-id
Fix preload command support for application IDs
2020-12-11 16:41:03 +00:00
10bca728f0 v12.33.0 2020-12-11 17:12:40 +02:00
9763a14e97 Merge pull request #2135 from balena-io/add-orgs
Add orgs command
2020-12-11 15:10:13 +00:00
fe24280adf Fix preload command support for application IDs
Change-type: patch
Resolves: #2063
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-11 13:54:31 +00:00
a11f9ec705 Add orgs command
Change-type: minor
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-11 12:48:44 +01:00
836ae1cf4a v12.32.2 2020-12-11 03:11:16 +02:00
b4d37e7a3a Merge pull request #2133 from balena-io/apps-column-match-not-a-function
apps: Fix "column.match is not a function" when --verbose is used
2020-12-11 01:08:46 +00:00
055ad834e7 apps: Fix "column.match is not a function" when --verbose is used
Change-type: patch
2020-12-11 00:31:59 +00:00
d2cb88dfb8 v12.32.1 2020-12-11 02:30:10 +02:00
d096743e78 Merge pull request #2130 from balena-io/ab77/onprem-refresh
Make balena-cli build on refreshed on-prem workers
2020-12-11 00:26:45 +00:00
511d0dbe26 Make balena-cli build on refreshed on-prem workers
* Fix 'balena ssh' test cases when using the Windows built-in ssh tool
* Fix Windows installer build in new balena CI workers (qqjs patch)
* Remove hardcoded path

Change-type: patch
2020-12-10 12:30:25 -08:00
6b0201866f v12.32.0 2020-12-10 18:42:18 +02:00
9e20b2b691 Merge pull request #2128 from balena-io/app-rename-orgs
Add organizations support to app rename command
2020-12-10 16:40:18 +00:00
665e0cf9d7 Add organizations support to app rename command
Change-type: minor
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-10 13:57:42 +01:00
b319ec7281 v12.31.0 2020-12-10 14:38:47 +02:00
ae3ccf759f Merge pull request #2113 from balena-io/1828-livepush-connection-lost
Livepush, logs: Automatically reconnect on 'Connection to device lost'
2020-12-10 12:36:45 +00:00
309b1ba6a0 v12.30.4 2020-12-10 10:20:29 +02:00
532c4a1862 Merge pull request #2125 from balena-io/fix-app-display
Fix app name output in app command
2020-12-10 08:18:28 +00:00
fc8b7c71fc Fix app name output in app command
Change-type: patch
Resolves: #2120
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-10 08:32:20 +01:00
07666e953f Livepush: Extend CTRL-C availability (don't ignore CTRL-C during image build)
Change-type: patch
2020-12-09 22:49:47 +00:00
54731c2d20 Livepush, logs: Automatically reconnect on 'Connection to device lost'
Change-type: minor
2020-12-09 20:43:14 +00:00
d00db5ea8c logs: Fix CTRL-C ignored on Windows (PowerShell, MSYS, Git for Windows)
Change-type: patch
2020-12-09 20:43:14 +00:00
5497835728 Livepush: Fix process not exiting on "Connection to device lost"
Resolves: #1828
Change-type: patch
2020-12-09 20:43:14 +00:00
5bb05f3a8c v12.30.3 2020-12-09 19:07:19 +02:00
659eda8cd1 Merge pull request #2117 from balena-io/add_device_deactivation_expected_errors
errors: Add expected errors for device deactivation
2020-12-09 17:05:41 +00:00
a19132d3bf errors: Add expected errors for device deactivation
Change-type: patch
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-09 14:52:51 +02:00
140993f554 v12.30.2 2020-12-08 13:02:07 +02:00
575eaf6de1 Merge pull request #2116 from balena-io/remove-v12
Remove remaining v12 switches
2020-12-08 11:00:07 +00:00
3edf7a038f Remove remaining v12 switches
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-08 11:00:36 +01:00
ad16c5270e v12.30.1 2020-12-07 16:14:13 +02:00
adadefdf3f Merge pull request #2115 from balena-io/fix-booleans
Standardize boolean flag typing
2020-12-07 14:12:14 +00:00
19fab40398 Standardize boolean flag typing
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-12-07 14:36:29 +01:00
4dc53eb056 v12.30.0 2020-12-07 14:30:09 +02:00
9c96da7515 Merge pull request #2112 from balena-io/add_deactive_cmd
device: Add deactivate command
2020-12-07 12:28:24 +00:00
8a3e386d21 packages: Bump balena-sdk and balena-errors
Update balena-sdk from 15.6.0 to 15.20.0
Update balena-errors from 4.4.1 to 4.7.1

Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-07 13:19:02 +02:00
5eaa4cfb9f common-flags: Add default false on yes, force and verbose flags
Change-type: patch
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-07 13:17:51 +02:00
cb2b90732b device: Add deactivate command
Resolves: #1545
Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-07 13:12:50 +02:00
090fc58d10 v12.29.1 2020-12-04 03:22:19 +02:00
3b05971098 Merge pull request #2114 from balena-io/devices-full-uuid
devices: Don't truncate device UUID to 7 chars when --json is used
2020-12-04 01:20:17 +00:00
aae6aff3e9 devices: Don't truncate device UUID to 7 chars when --json is used
Change-type: patch
2020-12-04 00:45:03 +00:00
93 changed files with 5303 additions and 1957 deletions

37
.dockerignore Normal file
View File

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

69
.gitignore vendored
View File

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

View File

@ -17,4 +17,75 @@ npm:
- "14"
docker:
publish: false
builds:
- path: .
dockerfile: ./docker/alpine/Dockerfile
docker_repo: balenalib/amd64-alpine-balenacli
args:
- BUILD_BASE=balenalib/amd64-alpine-node:12.19.1-build-20201211
- RUN_BASE=balenalib/amd64-alpine-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/alpine/Dockerfile
docker_repo: balenalib/armv7hf-alpine-balenacli
args:
- BUILD_BASE=balenalib/armv7hf-alpine-node:12.19.1-build-20201211
- RUN_BASE=balenalib/armv7hf-alpine-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/alpine/Dockerfile
docker_repo: balenalib/i386-alpine-balenacli
args:
- BUILD_BASE=balenalib/i386-alpine-node:12.19.1-build-20201211
- RUN_BASE=balenalib/i386-alpine-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/alpine/Dockerfile
docker_repo: balenalib/rpi-alpine-balenacli
args:
- BUILD_BASE=balenalib/rpi-alpine-node:12.19.1-build-20201211
- RUN_BASE=balenalib/rpi-alpine-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/debian/Dockerfile
docker_repo: balenalib/aarch64-debian-balenacli
args:
- BUILD_BASE=balenalib/aarch64-debian-node:12.19.1-build-20201118
- RUN_BASE=balenalib/aarch64-debian-node:12.19.1-run-20201118
publish: true
- path: .
dockerfile: ./docker/debian/Dockerfile
docker_repo: balenalib/amd64-debian-balenacli
args:
- BUILD_BASE=balenalib/amd64-debian-node:12.19.1-build-20201211
- RUN_BASE=balenalib/amd64-debian-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/debian/Dockerfile
docker_repo: balenalib/armv7hf-debian-balenacli
args:
- BUILD_BASE=balenalib/armv7hf-debian-node:12.19.1-build-20201211
- RUN_BASE=balenalib/armv7hf-debian-node:12.19.1-run-20201211
publish: true
- path: .
dockerfile: ./docker/debian/Dockerfile
docker_repo: balenalib/i386-debian-balenacli
args:
- BUILD_BASE=balenalib/i386-debian-node:12.16.3-build-20200518
- RUN_BASE=balenalib/i386-debian-node:12.16.3-build-20200518
publish: true
- path: .
dockerfile: ./docker/debian/Dockerfile
docker_repo: balenalib/rpi-debian-balenacli
args:
- BUILD_BASE=balenalib/rpi-debian-node:12.19.1-build-20201211
- RUN_BASE=balenalib/rpi-debian-node:12.19.1-run-20201211
publish: true

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,375 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## 12.42.0 - 2021-03-19
* balena device: Display public IP address field [Chris Wiggins]
* Update balena-sdk from 15.20.0 to 15.29.0 [Chris Wiggins]
## 12.41.3 - 2021-03-17
* Update supported npm version range in package.json (<7.0.0) [Paulo Castro]
## 12.41.2 - 2021-03-17
* Linux installation instructions: Add sudo configuration section [Paulo Castro]
## 12.41.1 - 2021-03-15
* docker: Fix path to init when workdir is changed [Kyle Harding]
## 12.41.0 - 2021-03-15
* docker: Add Docker images with the CLI and Docker-in-Docker [Kyle Harding]
## 12.40.4 - 2021-03-09
* macOS GUI installer: Update signing certificate name [Paulo Castro]
## 12.40.3 - 2021-03-06
* build, deploy: Fix error handling when QEMU download fails [Paulo Castro]
## 12.40.2 - 2021-02-23
* docs: Fix missing markdown docs for device `deactivate` and `local-mode` [Paulo Castro]
## 12.40.1 - 2021-02-23
* docs: emphasize that push emulation is not required in most cases [Kyle Harding]
## 12.40.0 - 2021-02-09
* Add support for docker-compose dev overlay in local pushes [Scott Lowe]
## 12.39.1 - 2021-02-06
* build/deploy: fix emulated builds to use fully static qemu binaries [Kyle Harding]
## 12.39.0 - 2021-02-04
* Add command `device local-mode` [Scott Lowe]
## 12.38.10 - 2021-02-04
* Improve build-time checks (automation/test-lock-deduplicated.sh) [Paulo Castro]
## 12.38.9 - 2021-02-04
* Modify push to pass app slug to builder [Scott Lowe]
## 12.38.8 - 2021-01-29
* build/deploy: Update QEMU to speed up emulated builds [Kyle Harding]
## 12.38.7 - 2021-01-25
* tunnel: Add note re openBalena version compatibility [Paulo Castro]
## 12.38.6 - 2021-01-22
* logging: note that the device supervisor version is operative [Matthew McGinn]
## 12.38.5 - 2021-01-21
* tls: Use TLS for tunnel connection [Balena CI]
## 12.38.4 - 2021-01-21
* Update supported Node.js version range in package.json (<13.0.0) [Paulo Castro]
## 12.38.3 - 2021-01-19
* Handle 'push' edge case with application access [Scott Lowe]
## 12.38.2 - 2021-01-18
* Delete old config files for Travis and AppVeyor to avoid confusion [Paulo Castro]
## 12.38.1 - 2021-01-15
* Fix handling of thrown strings [Scott Lowe]
## 12.38.0 - 2021-01-14
* deploy: Add --release-tag flag [Marios Balamatsias]
## 12.37.2 - 2021-01-13
* docs: americanize the spelling of words in sourced markdown [Matthew McGinn]
## 12.37.1 - 2021-01-06
* Refactor out command internal scandevices [Scott Lowe]
## 12.37.0 - 2020-12-28
* Add --additional-space flag to preload [Alexis Svinartchouk]
* Update balena-preload to 10.4.1 [Alexis Svinartchouk]
## 12.36.1 - 2020-12-23
* Update push command for organizations [Scott Lowe]
## 12.36.0 - 2020-12-22
* errors: Make all exclusive flag errors expected [Marios Balamatsias]
* push: Add --release-tag flag [Marios Balamatsias]
## 12.35.3 - 2020-12-21
* Improve id disambiguation for tag commands [Scott Lowe]
## 12.35.2 - 2020-12-18
* Modify handling of SIGINT in balena-dev [Scott Lowe]
## 12.35.1 - 2020-12-17
* Update commands ssh, tunnel to support orgs [Scott Lowe]
## 12.35.0 - 2020-12-15
* Update various commands to support organizations [Scott Lowe]
## 12.34.0 - 2020-12-15
* Add organizations support to app create command [Scott Lowe]
## 12.33.2 - 2020-12-15
* Improve error message for issues to access balena settings [josecoelho]
## 12.33.1 - 2020-12-11
* Fix preload command support for application IDs [Scott Lowe]
## 12.33.0 - 2020-12-11
* Add orgs command [Scott Lowe]
## 12.32.2 - 2020-12-11
* apps: Fix "column.match is not a function" when --verbose is used [Paulo Castro]
## 12.32.1 - 2020-12-10
* Make balena-cli build on refreshed on-prem workers [Paulo Castro]
## 12.32.0 - 2020-12-10
* Add organizations support to app rename command [Scott Lowe]
## 12.31.0 - 2020-12-10
* Livepush: Extend CTRL-C availability (don't ignore CTRL-C during image build) [Paulo Castro]
* Livepush, logs: Automatically reconnect on 'Connection to device lost' [Paulo Castro]
* logs: Fix CTRL-C ignored on Windows (PowerShell, MSYS, Git for Windows) [Paulo Castro]
* Livepush: Fix process not exiting on "Connection to device lost" [Paulo Castro]
## 12.30.4 - 2020-12-10
* Fix app name output in app command [Scott Lowe]
## 12.30.3 - 2020-12-09
* errors: Add expected errors for device deactivation [Marios Balamatsias]
## 12.30.2 - 2020-12-08
* Remove remaining v12 switches [Scott Lowe]
## 12.30.1 - 2020-12-07
* Standardize boolean flag typing [Scott Lowe]
## 12.30.0 - 2020-12-07
<details>
<summary> packages: Bump balena-sdk and balena-errors [Marios Balamatsias] </summary>
> ### balena-sdk-15.20.0 - 2020-12-04
>
> * device: Add deactivate method [Marios Balamatsias]
>
> ### balena-sdk-15.19.0 - 2020-12-02
>
> * Add missing application and release typings [Stevche Radevski]
>
> ### balena-sdk-15.18.1 - 2020-11-20
>
> * Bump typescript to 4.1 [Thodoris Greasidis]
>
> ### balena-sdk-15.18.0 - 2020-11-19
>
> * typings: Deprecate PineWithSelectOnGet variant in favor of PineStrict [Thodoris Greasidis]
>
> <details>
> <summary> Update balena-auth from 4.0.2 to 4.1.0 [josecoelho] </summary>
>
>> #### balena-request-11.2.0 - 2020-11-12
>>
>> * Update balena-auth from 4.0.0 to 4.1.0 [josecoelho]
>>
> </details>
>
>
> ### balena-sdk-15.17.0 - 2020-10-27
>
> * Add missing reverse navigation relations to User typings [Thodoris Greasidis]
>
> ### balena-sdk-15.16.0 - 2020-10-23
>
> * Add SDK methods for org invites [Amit Solanki]
>
> ### balena-sdk-15.15.0 - 2020-10-22
>
> * Modify the os update to check against hostapp release [Stevche Radevski]
>
> ### balena-sdk-15.14.0 - 2020-10-19
>
> * Prevent invalid $selects in strict pine.get variant calls [Thodoris Greasidis]
> * Improve the parameter type checks for the fully typed pine.get [Thodoris Greasidis]
>
> ### balena-sdk-15.13.0 - 2020-10-09
>
> * Pass shouldFlatten through when creating release from url [Stevche Radevski]
>
> ### balena-sdk-15.12.1 - 2020-09-20
>
> * Time the test suites [Thodoris Greasidis]
> * Combine test util files from before dropping coffeescript [Thodoris Greasidis]
>
> ### balena-sdk-15.12.0 - 2020-09-20
>
> * Application: add rename method [JSReds]
>
> ### balena-sdk-15.11.3 - 2020-09-19
>
> * tests/keys: Fix race condition [Thodoris Greasidis]
>
> ### balena-sdk-15.11.2 - 2020-09-19
>
> * tests/device: Combine some multicontainer app tests [Thodoris Greasidis]
> * Remove some beforeEach() from the device tests [Thodoris Greasidis]
>
> ### balena-sdk-15.11.1 - 2020-09-19
>
> * Fix the device.setSupervisorRelease() tests [Thodoris Greasidis]
>
> ### balena-sdk-15.11.0 - 2020-09-14
>
> * Typings: Extend the supported billing cycles [Thodoris Greasidis]
>
> ### balena-sdk-15.10.6 - 2020-09-14
>
> * tests: Reduce the application creations & teardowns even further [Thodoris Greasidis]
>
> ### balena-sdk-15.10.5 - 2020-09-14
>
> * Login: add new error handling, update balena-errors [JSReds]
>
> ### balena-sdk-15.10.4 - 2020-09-11
>
> * tests: Reduce the application creations & teardowns [Thodoris Greasidis]
>
> ### balena-sdk-15.10.3 - 2020-09-11
>
> * tests: Use mocha.parallel to speed up the test cases [Thodoris Greasidis]
>
> ### balena-sdk-15.10.2 - 2020-09-11
>
> * tests: Remove some before/afterEach calls to speed up the tests [Thodoris Greasidis]
>
> ### balena-sdk-15.10.1 - 2020-09-10
>
> * tests: Test that the result of device.getDeviceSlug() is a string [Thodoris Greasidis]
> * tests: Run device.getDeviceBySlug() calls in parallel to speed up tests [Thodoris Greasidis]
> * tests/os: Drop unnecessary beforeEach in getConfig() [Thodoris Greasidis]
> * tests/application: Fix incorrect skipping of unauthenticated tests [Thodoris Greasidis]
>
> ### balena-sdk-15.10.0 - 2020-09-10
>
> * typings: Make ReleaseWithImageDetails more accurate [Thodoris Greasidis]
> * Fully type the pine.get results [Thodoris Greasidis]
> * typings: Add the PineTypedResult helper type [Thodoris Greasidis]
>
> ### balena-sdk-15.9.1 - 2020-09-09
>
> * Typings: Add organization member relation to tags [Thodoris Greasidis]
>
> ### balena-sdk-15.9.0 - 2020-09-08
>
> * Add typings for pine.getOrCreate() [Thodoris Greasidis]
>
> <details>
> <summary> Bump balena-pine to add getOrCreate [Thodoris Greasidis] </summary>
>
>> #### balena-pine-12.4.0 - 2020-09-07
>>
>>
>> <details>
>> <summary> Update pinejs-client-core to 6.9.0 to support getOrCreate() [Thodoris Greasidis] </summary>
>>
>>> ##### pinejs-client-js-6.9.0 - 2020-09-07
>>>
>>> * Add 'getOrCreate' method supporting natural keys [Thodoris Greasidis]
>>>
>>> ##### pinejs-client-js-6.8.0 - 2020-09-03
>>>
>>> * Add support for $format [Pagan Gazzard]
>>>
>>> ##### pinejs-client-js-6.7.3 - 2020-08-26
>>>
>>> * Improve $orderby typing to allow `[{a: 'desc'}, {b: 'asc'}]` [Pagan Gazzard]
>>>
>>> ##### pinejs-client-js-6.7.2 - 2020-08-24
>>>
>>> * Update dev dependencies [Pagan Gazzard]
>>>
>>> ##### pinejs-client-js-6.7.1 - 2020-08-12
>>>
>>> * Fix prepare $count typings [Pagan Gazzard]
>>>
>>> ##### pinejs-client-js-6.7.0 - 2020-08-12
>>>
>>> * Improve typings for request/post/put/patch/delete [Pagan Gazzard]
>>>
>> </details>
>>
>>
> </details>
>
>
> ### balena-sdk-15.8.1 - 2020-09-08
>
> * Add mocha tests specific linting [Thodoris Greasidis]
> * Auto-fix lint errors with the test:fast script [Thodoris Greasidis]
> * Add linting checks back to the test script [Thodoris Greasidis]
>
> ### balena-sdk-15.8.0 - 2020-09-08
>
> * Add a hostapps model for fetching OS versions [Stevche Radevski]
>
> ### balena-sdk-15.7.1 - 2020-09-03
>
> * tests: Convert the device.getMACAddress tests to async await [Thodoris Greasidis]
>
> ### balena-sdk-15.7.0 - 2020-09-03
>
> * Add methods for managing organization membership tags [Thodoris Greasidis]
> * tests: Support testing tags with two word names [Thodoris Greasidis]
>
</details>
* common-flags: Add default false on yes, force and verbose flags [Marios Balamatsias]
* device: Add deactivate command [Marios Balamatsias]
## 12.29.1 - 2020-12-04
* devices: Don't truncate device UUID to 7 chars when --json is used [Paulo Castro]
## 12.29.0 - 2020-12-01
* scan: Print production devices' info on scan [Marios Balamatsias]

View File

@ -13,6 +13,8 @@ There are 3 options to choose from to install balena's CLI:
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.
* [Docker Installation](#docker-installation): recommended for users that would like to run the
CLI on edge devices or systems where npm installation may not be an option.
Some specific CLI commands have a few extra installation steps: see section [Additional
Dependencies](#additional-dependencies).
@ -83,9 +85,7 @@ some additional development tools to be installed first:
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.
"npm install".
* [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++`
@ -148,3 +148,10 @@ To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machi
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).
## Docker Installation
[balenalib images](https://www.balena.io/docs/reference/base-images/base-images/)
are available for the balena CLI. They can be used interactively with `docker run`, or
as a base image for your application containers. Check out [Docker.md](docker/DOCKER.md)
on how to pick an image and get started!

View File

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

View File

@ -29,10 +29,10 @@ and `preload` commands may require additional software to be installed, as descr
### 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
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote
machine). Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation (laptop) as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh

View File

@ -30,10 +30,10 @@ described below.
### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will 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
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote
machine). Most users will follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same
workstation (laptop) as the balena CLI. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh

View File

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

View File

@ -59,8 +59,10 @@ const capitanoDoc = {
'build/commands/devices/index.js',
'build/commands/devices/supported.js',
'build/commands/device/index.js',
'build/commands/device/deactivate.js',
'build/commands/device/identify.js',
'build/commands/device/init.js',
'build/commands/device/local-mode.js',
'build/commands/device/move.js',
'build/commands/device/os-update.js',
'build/commands/device/public-url.js',

View File

@ -10,7 +10,10 @@ 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' **";
echo "** This can usually be fixed with: **";
echo "** git checkout master -- npm-shrinkwrap.json **";
echo "** rm -rf node_modules **";
echo "** npm install && npm dedupe && npm install **";
exit 1;
fi

View File

@ -20,7 +20,8 @@ import * as _ from 'lodash';
import * as path from 'path';
import * as shellEscape from 'shell-escape';
export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
export const MSYS2_BASH =
process.env.MSYSSHELLPATH || 'C:\\msys64\\usr\\bin\\bash.exe';
export const ROOT = path.join(__dirname, '..');
/** Tap and buffer this process' stdout and stderr */

View File

@ -21,9 +21,14 @@ modifyOclifPaths();
process.on('exit', function () {
modifyOclifPaths(true);
});
// Undo changes in case of ctrl-v
// Undo changes in case of ctrl-c
process.on('SIGINT', function () {
modifyOclifPaths(true);
// Note process exit here will interfere with commands that do their own SIGINT handling,
// but without it commands can not be exited.
// So currently using balena-dev does not guarantee proper exit behaviour when using ctrl-c.
// Ideally a better solution is needed.
process.exit();
});
// Use fast-boot to cache require lookups, speeding up startup

File diff suppressed because it is too large Load Diff

410
docker/DOCKER.md Normal file
View File

@ -0,0 +1,410 @@
# Docker Images for the balena CLI
Docker images with the balena CLI and Docker-in-Docker.
## Features Overview
These CLI images are based on the popular [Balena base images](https://www.balena.io/docs/reference/base-images/base-images/)
so they include many of the features you see there.
- Multiple Architectures:
- `rpi`
- `armv7hf`
- `aarch64` (debian only)
- `amd64`
- `i386`
- Multiple Distributions
- `debian`
- `alpine`
- [cross-build](https://www.balena.io/docs/reference/base-images/base-images/#building-arm-containers-on-x86-machines) functionality for building ARM containers on x86.
- Helpful package installer script called `install_packages` inspired by [minideb](https://github.com/bitnami/minideb#why-use-minideb).
Note that there are some additional considerations when running the CLI via Docker so
pay close attention to the [Usage](#usage) section for examples of different CLI commands.
## Image Names
`balenalib/<arch>-<distro>-balenacli:<cli_ver>`
- `<arch>` is the architecture and is mandatory. If using `Dockerfile.template`, you can replace this with `%%BALENA_ARCH%%`.
For a list of available device names and architectures, see the [Device types](https://www.balena.io/docs/reference/base-images/devicetypes/).
- `<distro>` is the Linux distribution and is mandatory. Currently there are 2 distributions, namely `debian` and `alpine`.
## Image Tags
In the tags, all of the fields are optional, and if they are left out, they will default to their `latest` pointer.
- `<cli_ver>` is the version of the balena CLI, for example, `12.40.2`, it can also be substituted for `latest`.
## Examples
`balenalib/amd64-debian-balenacli:12.40.2`
- `<arch>`: amd64 - suitable for running on most workstations
- `<distro>`: debian - widely used base distro
- `<cli_ver>`: 12.40.2
`balenalib/armv7hf-alpine-balenacli`
- `<arch>`: armv7hf - suitable for running on a Raspberry Pi 3 for example
- `<distro>`: alpine - smaller footprint than debian
- `<cli_ver>`: omitted - the latest available CLI version will be used
## Volumes
Volumes can be used to persist data between instances of the CLI container, or to share
files between the host and the container.
In most cases these are optional, but some examples will highlight when volumes are required.
- `-v "balena_data:/root/.balena"`: persist balena credentials and downloads between instances
- `-v "docker_data:/var/lib/docker"`: persist cache between instances when using Docker-in-Docker (requires `-e "DOCKERD=1"`)
- `-v "$PWD:$PWD" -w "$PWD"`: bind mount your current working directory in the container to share app sources or balenaOS image files
- `-v "${SSH_AUTH_SOCK}:/ssh-agent"`: bind mount your host ssh-agent socket with preloaded SSH keys
- `-v "/var/run/docker.sock:/var/run/docker.sock"`: bind mount your host Docker socket instead of Docker-in-Docker
## Environment Variables
These environment variables are available for additional functionality included in the CLI image.
In most cases these are optional, but some examples will highlight when environment variables are required.
- `-e "SSH_PRIVATE_KEY=$(</path/to/priv/key)"`: copy your private SSH key file contents as an environment variable
- `-e "DOCKERD=1"`: enable the included Docker-in-Docker daemon (requires `--cap-add SYS_ADMIN`)
## Keeping the CLI image up to date
Please note that using the `:latest` tag is not enough to keep the image up to date,
because Docker will reuse a locally cached image. To update the image to the latest
version, run:
```bash
$ docker pull balenalib/<arch>-<distro>-balenacli
```
Replacing `<arch>` and `<distro>` with the image architecture and distribution as
described earlier.
If you are using Docker v19.09 or later, you can also add the `--pull always` flag to
`docker run` commands, so that Docker automatically checks for available updates
(new image layers will only be downloaded if a new version is available).
## Usage
We've provided some examples of common CLI commands and how they are best used
with this image, since some special considerations must be made.
- [login](#login) - login to balena
- [push](#push) - start a build on the remote balenaCloud build servers, or a local mode device
- [logs](#logs) - show device logs
- [ssh](#ssh) - SSH into the host or application container of a device
- [apps](#app--apps) - list all applications
- [app](#app--apps) - display information about a single application
- [devices](#device--devices) - list all devices
- [device](#device--devices) - show info about a single device
- [tunnel](#tunnel) - tunnel local ports to your balenaOS device
- [preload](#preload) - preload an app on a disk image (or Edison zip archive)
- [build](#build--deploy) - build a project locally
- [deploy](#build--deploy) - deploy a single image or a multicontainer project to a balena application
- [join](#join--leave) - move a local device to an application on another balena server
- [leave](#join--leave) - remove a local device from its balena application
- [scan](#scan) - scan for balenaOS devices on your local network
For each example we have also linked to the corresponding sections of the
balena CLI Documentation here: https://www.balena.io/docs/reference/balena-cli
### login
- <https://www.balena.io/docs/reference/balena-cli/#login>
The `balena login` command can't be used with web authorization and a browser
when running in a container. Instead it must be used with `--token` or `--credentials`.
Notice that here we've used a named volume `balena_data` to store credentials
for future runs of the CLI image. This is optional but avoids having to run the login
command again every time you run the image.
```bash
$ docker volume create balena_data
$ docker run --rm -it -v "balena_data:/root/.balena" balenalib/amd64-debian-balenacli /bin/bash
> balena login --credentials --email "johndoe@gmail.com" --password "secret"
> balena login --token "..."
> exit
```
### push
- <https://www.balena.io/docs/reference/balena-cli/#push-applicationordevice>
In this example we are mounting your current working directory into the container with `-v "$PWD:$PWD" -w "$PWD"`.
This will bind mount your current working directory into the container at the same absolute path.
This bind mount is required so the CLI has access to your app sources.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "$PWD:$PWD" -w "$PWD" \
balenalib/amd64-debian-balenacli /bin/bash
> balena push myApp --source .
> balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value
> exit
```
### logs
- <https://www.balena.io/docs/reference/balena-cli/#logs-device>
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
balenalib/amd64-debian-balenacli /bin/bash
> balena logs 23c73a1 --service my-service
> balena logs 23c73a1.local --system --tail
> exit
```
### ssh
- <https://www.balena.io/docs/reference/balena-cli/#key-add-name-path>
- <https://www.balena.io/docs/reference/balena-cli/#ssh-applicationordevice-service>
The `balena ssh` command requires an existing SSH key added to your balenaCloud
account.
One way to make this key available to the container is to pass the private key file contents as an environment variable.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-e "SSH_PRIVATE_KEY=$(</path/to/priv/key)" \
balenalib/amd64-debian-balenacli /bin/bash
> balena ssh f49cefd
> balena ssh f49cefd my-service
> balena ssh 192.168.0.1 --verbose
> exit
```
Another way to share SSH keys with the container is to mount your SSH agent socket with keys preloaded.
```bash
$ eval ssh-agent
$ ssh-add /path/to/priv/key
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "${SSH_AUTH_SOCK}:/ssh-agent" \
balenalib/amd64-debian-balenacli /bin/bash
> balena ssh f49cefd
> balena ssh f49cefd my-service
> balena ssh 192.168.0.1 --verbose
> exit
```
### app | apps
- <https://www.balena.io/docs/reference/balena-cli/#app-nameorslug>
- <https://www.balena.io/docs/reference/balena-cli/#apps>
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
balenalib/amd64-debian-balenacli /bin/bash
> balena apps
> balena app myorg/myapp
> exit
```
### device | devices
- <https://www.balena.io/docs/reference/balena-cli/#device-uuid>
- <https://www.balena.io/docs/reference/balena-cli/#devices>
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
balenalib/amd64-debian-balenacli /bin/bash
> balena devices --application MyApp
> balena device 7cf02a6
> exit
```
### tunnel
- <https://www.balena.io/docs/reference/balena-cli/#tunnel-deviceorapplication>
The `balena tunnel` command is easiest used when the host networking stack
can be shared with the container and ports can be easily assigned.
However the host networking driver only works on Linux hosts, and is not supported
on Docker Desktop for Mac, Docker Desktop for Windows, or Docker EE for Windows Server.
Instead you can bind specific port ranges to the host so you can access the tunnel
from outside the container via `localhost:[localPort]`.
Note that when exposing individual ports, you must specify all interfaces in the format
`[remotePort]:0.0.0.0:[localPort]` otherwise the tunnel will only be listening for
connections within the container.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-p 22222:22222 \
-p 12345:54321
balenalib/amd64-debian-balenacli /bin/bash
> balena tunnel 2ead211 -p 22222:0.0.0.0
> balena tunnel myApp -p 54321:0.0.0.0:12345
> exit
```
If you have host networking available then you do not need to specify ports
in your run command, and the interface `0.0.0.0` is optional in your tunnel command.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
--network host \
balenalib/amd64-debian-balenacli /bin/bash
> balena tunnel 2ead211 -p 22222
> balena tunnel myApp -p 54321:12345
> exit
```
### preload
- <https://www.balena.io/docs/reference/balena-cli/#os-download-type>
- <https://www.balena.io/docs/reference/balena-cli/#os-configure-image>
- <https://www.balena.io/docs/reference/balena-cli/#preload-image>
The `balena preload` command requires access to a Docker client and daemon.
The easiest way to run this command is to use the included Docker-in-Docker daemon.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "docker_data:/var/lib/docker" \
-e "DOCKERD=1" --cap-add SYS_ADMIN \
balenalib/amd64-debian-balenacli /bin/bash
> balena os download raspberrypi3 -o raspberry-pi.img
> balena os configure raspberry-pi.img --app MyApp
> balena preload raspberry-pi.img --app MyApp --commit current
> exit
```
Another way to run the `preload` command is to use the host OS Docker socket and avoid
starting a Docker daemon in the container. This is achieved with `-v "/var/run/docker.sock:/var/run/docker.sock"`.
In this example we are mounting your current working directory into the container with `-v "$PWD:$PWD" -w "$PWD"`.
This will bind mount your current working directory into the container at the same absolute path.
This bind mount is required when using the host Docker socket because the absolute path to the balenaOS image
file must be the same from both the perspective of the CLI in the container and the host Docker socket.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "/var/run/docker.sock:/var/run/docker.sock" \
-v "$PWD:$PWD" -w "$PWD" \
balenalib/amd64-debian-balenacli /bin/bash
> balena os download raspberrypi3 -o raspberry-pi.img
> balena os configure raspberry-pi.img --app MyApp
> balena preload raspberry-pi.img --app MyApp --commit current
> exit
```
### build | deploy
- <https://www.balena.io/docs/reference/balena-cli/#build-source>
- <https://www.balena.io/docs/reference/balena-cli/#deploy-appname-image>
The `build` and `deploy` commands both require access to a Docker client and daemon.
The easiest way to run these commands is to use the included Docker-in-Docker daemon.
In this example we are mounting your current working directory into the container with `-v "$PWD:$PWD" -w "$PWD"`.
This will bind mount your current working directory into the container at the same absolute path.
This bind mount is required so the CLI has access to your app sources.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "docker_data:/var/lib/docker" \
-e DOCKERD=1 --cap-add SYS_ADMIN \
-v "$PWD:$PWD" -w "$PWD" \
balenalib/amd64-debian-balenacli /bin/bash
> balena build --application myApp
> balena deploy myApp
> exit
```
Another way to run the `build` and `deploy` commands is to use the host OS Docker socket and avoid
starting a Docker daemon in the container. This is achieved with `-v "/var/run/docker.sock:/var/run/docker.sock"`.
In this example we are mounting your current working directory into the container with `-v "$PWD:$PWD" -w "$PWD"`.
This will bind mount your current working directory into the container at the same absolute path.
This bind mount is required so the CLI has access to your app sources.
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
-v "/var/run/docker.sock:/var/run/docker.sock" \
-v "$PWD:$PWD" -w "$PWD" \
balenalib/amd64-debian-balenacli /bin/bash
> balena build --application myApp
> balena deploy myApp
> exit
```
### join | leave
- <https://www.balena.io/docs/reference/balena-cli/#join-deviceiporhostname>
- <https://www.balena.io/docs/reference/balena-cli/#leave-deviceiporhostname>
```bash
$ docker run --rm -it -v "balena_data:/root/.balena" \
balenalib/amd64-debian-balenacli /bin/bash
> balena join balena.local --application MyApp
> balena leave balena.local
> exit
```
### scan
- <https://www.balena.io/docs/reference/balena-cli/#scan>
The `balena scan` command requires access to the host network interface
in order to bind and listen for multicast responses from devices.
However the host networking driver only works on Linux hosts, and is not supported
on Docker Desktop for Mac, Docker Desktop for Windows, or Docker EE for Windows Server.
```bash
$ docker run --rm -it --network host balenalib/amd64-debian-balenacli scan
```
## Custom images / contributing
The following steps may be used to create custom CLI images or
to contribute bug reports, fixes or features.
```bash
# the currently supported base images are 'debian' and 'alpine'
export BALENA_DISTRO="debian"
# provide the architecture where you will be testing the image
export BALENA_ARCH="amd64"
# optionally register QEMU binfmt if building for other architectures (eg. armv7hf)
$ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# build and tag an image with docker
docker build . -f docker/${BALENA_DISTRO}/Dockerfile \
--build-arg "BUILD_BASE=balenalib/${BALENA_ARCH}-${BALENA_DISTRO}-node:12.19.1-build" \
--build-arg "RUN_BASE=balenalib/${BALENA_ARCH}-${BALENA_DISTRO}-node:12.19.1-run" \
--tag "balenalib/${BALENA_ARCH}-${BALENA_DISTRO}-balenacli"
```

43
docker/alpine/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
ARG BUILD_BASE=balenalib/amd64-alpine-node:12.19.1-build
ARG RUN_BASE=balenalib/amd64-alpine-node:12.19.1-run
FROM ${BUILD_BASE} as build
WORKDIR /usr/src/app
COPY . .
# dev dependencies are required for build:fast
# --unsafe-perm is not needed because of global /usr/local/etc/npmrc
RUN npm install
RUN npm run build:fast
# remove dev dependencies after build:fast
RUN npm prune --production
FROM ${RUN_BASE}
WORKDIR /usr/src/app
COPY --from=build /usr/src/app/ .
ENV PATH $PATH:/usr/src/app/bin
# fail early if balena binary won't run
RUN balena --version
# https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md#additional-dependencies
RUN install_packages avahi bash ca-certificates docker jq openssh
COPY docker/docker-init.sh init.sh
RUN CLI_CMDS=$(jq -r '.commands | keys | map(.[0:index(":")]) | unique | join("\\ ")' < oclif.manifest.json); \
sed -ie "s/CLI_CMDS=\"help\"/CLI_CMDS=\"help\\ ${CLI_CMDS}\"/" init.sh && \
chmod +x init.sh
ENTRYPOINT [ "/usr/src/app/init.sh" ]
CMD [ "help" ]
ENV SSH_AUTH_SOCK "/ssh-agent"

43
docker/debian/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
ARG BUILD_BASE=balenalib/amd64-debian-node:12.19.1-build
ARG RUN_BASE=balenalib/amd64-debian-node:12.19.1-run
FROM ${BUILD_BASE} as build
WORKDIR /usr/src/app
COPY . .
# dev dependencies are required for build:fast
# --unsafe-perm is not needed because of global /usr/local/etc/npmrc
RUN npm install
RUN npm run build:fast
# remove dev dependencies after build:fast
RUN npm prune --production
FROM ${RUN_BASE}
WORKDIR /usr/src/app
COPY --from=build /usr/src/app/ .
ENV PATH $PATH:/usr/src/app/bin
# fail early if balena binary won't run
RUN balena --version
# https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md#additional-dependencies
RUN install_packages avahi-daemon ca-certificates docker.io jq openssh-client
COPY docker/docker-init.sh init.sh
RUN CLI_CMDS=$(jq -r '.commands | keys | map(.[0:index(":")]) | unique | join("\\ ")' < oclif.manifest.json); \
sed -ie "s/CLI_CMDS=\"help\"/CLI_CMDS=\"help\\ ${CLI_CMDS}\"/" init.sh && \
chmod +x init.sh
ENTRYPOINT [ "/usr/src/app/init.sh" ]
CMD [ "help" ]
ENV SSH_AUTH_SOCK "/ssh-agent"

30
docker/docker-init.sh Normal file
View File

@ -0,0 +1,30 @@
#!/bin/bash
# start dockerd if env var is set
if [ "${DOCKERD}" = "1" ]
then
[ -e /var/run/docker.sock ] && rm /var/run/docker.sock
dockerd &
fi
# load private ssh key if one is provided
if [ -n "${SSH_PRIVATE_KEY}" ]
then
# if an ssh agent socket was not provided, start our own agent
[ -e "${SSH_AUTH_SOCK}" ] || eval "$(ssh-agent -s)"
echo "${SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add -
fi
# space-separated list of balena CLI commands (filled in through `sed`
# in a Dockerfile RUN instruction)
CLI_CMDS="help"
# treat the provided command as a balena CLI arg...
# 1. if the first word matches a known entry in CLI_CMDS
# 2. OR if the first character is a hyphen (eg. -h or --debug)
if [[ " ${CLI_CMDS} " =~ " ${1} " ]] || [ "${1:0:1}" = "-" ]
then
exec balena "$@"
else
exec "$@"
fi

View File

@ -20,9 +20,10 @@ import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import type * as BalenaSDK from 'balena-sdk';
import type { Application } from 'balena-sdk';
interface FlagsDef {
organization?: string;
type?: string; // application device type
help: void;
}
@ -37,16 +38,26 @@ export default class AppCreateCmd extends Command {
Create a new balena application.
You can specify the application device type with the \`--type\` option.
Otherwise, an interactive dropdown will be shown for you to select from.
You can specify the organization the application should belong to using
the \`--organization\` option. The organization's handle, not its name,
should be provided. Organization handles can be listed with the
\`balena orgs\` command.
You can see a list of supported device types with:
The application's default device type is specified with the \`--type\` option.
The \`balena devices supported\` command can be used to list the available
device types.
Interactive dropdowns will be shown for selection if no device type or
organization is specified and there are multiple options to choose from.
If there is a single option to choose from, it will be chosen automatically.
This interactive behavior can be disabled by explicitly specifying a device
type and organization.
`;
$ balena devices supported
`;
public static examples = [
'$ balena app create MyApp',
'$ balena app create MyApp --type raspberry-pi',
'$ balena app create MyApp --organization mmyorg',
'$ balena app create MyApp -o myorg --type raspberry-pi',
];
public static args = [
@ -60,6 +71,11 @@ export default class AppCreateCmd extends Command {
public static usage = 'app create <name>';
public static flags: flags.Input<FlagsDef> = {
organization: flags.string({
char: 'o',
description:
'handle of the organization the application should belong to',
}),
type: flags.string({
char: 't',
description:
@ -75,30 +91,62 @@ export default class AppCreateCmd extends Command {
AppCreateCmd,
);
const balena = getBalenaSdk();
// Create application
// Ascertain device type
const deviceType =
options.type ||
(await (await import('../../utils/patterns')).selectDeviceType());
let application: BalenaSDK.Application;
// Ascertain organization
const organization =
options.organization?.toLowerCase() || (await this.getOrganization());
// Create application
let application: Application;
try {
application = await balena.models.application.create({
application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization: (await balena.auth.whoami())!,
organization,
});
} catch (err) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: application "${params.name}" already exists`,
`Error: application "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create applications in organization "${organization}".`,
);
}
throw err;
}
console.info(
`Application created: ${application.slug} (${deviceType}, id ${application.id})`,
// Output
const { isV13 } = await import('../../utils/version');
console.log(
isV13()
? `Application created: slug "${application.slug}", device type "${deviceType}"`
: `Application created: ${application.slug} (${deviceType}, id ${application.id})`,
);
}
async getOrganization() {
const { getOwnOrganizations } = await import('../../utils/sdk');
const organizations = await getOwnOrganizations(getBalenaSdk());
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
const { selectOrganization } = await import('../../utils/patterns');
return selectOrganization(organizations);
}
}
}

View File

@ -18,7 +18,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
import type { Release } from 'balena-sdk';
interface FlagsDef {
@ -26,7 +28,7 @@ interface FlagsDef {
}
interface ArgsDef {
name: string;
application: string;
}
export default class AppCmd extends Command {
@ -34,18 +36,14 @@ export default class AppCmd extends Command {
Display information about a single application.
Display detailed information about a single balena application.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = ['$ balena app MyApp'];
public static examples = ['$ balena app MyApp', '$ balena app myorg/myapp'];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
];
public static args = [ca.applicationRequired];
public static usage = 'app <name>';
public static usage = 'app <nameOrSlug>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -59,22 +57,29 @@ export default class AppCmd extends Command {
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' },
const application = (await getApplication(
getBalenaSdk(),
params.application,
{
$expand: {
is_for__device_type: { $select: 'slug' },
should_be_running__release: { $select: 'commit' },
},
},
})) as ApplicationWithDeviceType & {
)) as ApplicationWithDeviceType & {
should_be_running__release: [Release?];
// For display purposes:
device_type: string;
commit?: string;
};
// @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;
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${application.app_name}`);
console.log(
getVisuals().table.vertical(application, [
`$${application.app_name}$`,
'id',
'device_type',
'slug',

View File

@ -18,35 +18,36 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
application: string;
}
export default class AppRestartCmd extends Command {
export default class AppPurgeCmd 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,
},
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena app purge MyApp',
'$ balena app purge myorg/myapp',
];
public static usage = 'app purge <name>';
public static args = [ca.applicationRequired];
public static usage = 'app purge <application>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -55,21 +56,18 @@ export default class AppRestartCmd extends Command {
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppPurgeCmd);
const { getApplication } = await import('../../utils/sdk');
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;
}
// so we must first fetch the app to get it's id,
const application = await getApplication(balena, params.application);
try {
await balena.models.application.purge(nameOrId);
await balena.models.application.purge(application.id);
} catch (e) {
if (e.message.toLowerCase().includes('no online device(s) found')) {
// application.purge throws an error if no devices are online

View File

@ -16,18 +16,19 @@
*/
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 * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
import { applicationIdInfo } from '../../utils/messages';
import type { ApplicationType } from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
application: string;
newName?: string;
}
@ -39,25 +40,25 @@ export default class AppRenameCmd extends Command {
Note, if the \`newName\` parameter is omitted, it will be
prompted for interactively.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena app rename OldName',
'$ balena app rename OldName NewName',
'$ balena app rename myorg/oldname NewName',
];
public static args: Array<IArg<any>> = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
public static args = [
ca.applicationRequired,
{
name: 'newName',
description: 'the new name for the application',
},
];
public static usage = 'app rename <name> [newName]';
public static usage = 'app rename <application> [newName]';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -68,38 +69,37 @@ export default class AppRenameCmd extends Command {
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
const { ExpectedError, instanceOf } = await import('../../errors');
const { getApplication } = await import('../../utils/sdk');
const { validateApplicationName } = await import('../../utils/validation');
const { ExpectedError } = await import('../../errors');
const balena = getBalenaSdk();
// Get app
let app;
try {
app = await getApplication(balena, params.name, {
$expand: {
application_type: {
$select: ['is_legacy'],
},
// Disambiguate target application (if params.params is a number, it could either be an ID or a numerical name)
const { getApplication } = await import('../../utils/sdk');
const application = await getApplication(balena, params.application, {
$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) {
// Check app exists
if (!application) {
throw new ExpectedError(
`Application ${params.name} is of 'legacy' type, and cannot be renamed.`,
'Error: application ${params.nameOrSlug} not found.',
);
}
const { validateApplicationName } = await import('../../utils/validation');
// Check app supports renaming
const appType = (application.application_type as ApplicationType[])?.[0];
if (appType.is_legacy) {
throw new ExpectedError(
`Application ${params.application} is of 'legacy' type, and cannot be renamed.`,
);
}
// Ascertain new name
const newName =
params.newName ||
(await getCliForm().ask({
@ -109,28 +109,31 @@ export default class AppRenameCmd extends Command {
})) ||
'';
// Rename
try {
await this.renameApplication(balena, app.id, newName);
await balena.models.application.rename(application.id, newName);
} catch (e) {
// BalenaRequestError: Request error: Unique key constraint violated
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
if ((e.message || '').toLowerCase().includes('unique')) {
throw new ExpectedError(
`Error: application ${params.name} already exists.`,
`Error: application ${params.application} already exists.`,
);
}
throw e;
}
console.log(`Application ${params.name} renamed to ${newName}`);
}
// Get application again, to be sure of results
const renamedApplication = await balena.models.application.get(
application.id,
);
async renameApplication(balena: BalenaSDK, id: number, newName: string) {
return balena.pine.patch<Application>({
resource: 'application',
id,
body: {
app_name: newName,
},
});
// Output result
console.log(`Application renamed`);
console.log('From:');
console.log(`\tname: ${application.app_name}`);
console.log(`\tslug: ${application.slug}`);
console.log('To:');
console.log(`\tname: ${renamedApplication.app_name}`);
console.log(`\tslug: ${renamedApplication.slug}`);
}
}

View File

@ -18,15 +18,16 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
application: string;
}
export default class AppRestartCmd extends Command {
@ -34,18 +35,18 @@ export default class AppRestartCmd extends Command {
Restart an application.
Restart all devices belonging to an application.
`;
public static examples = ['$ balena app restart MyApp'];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena app restart MyApp',
'$ balena app restart myorg/myapp',
];
public static usage = 'app restart <name>';
public static args = [ca.applicationRequired];
public static usage = 'app restart <application>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
@ -56,6 +57,13 @@ export default class AppRestartCmd extends Command {
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
await getBalenaSdk().models.application.restart(tryAsInteger(params.name));
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const application = await getApplication(balena, params.application);
await balena.models.application.restart(application.id);
}
}

View File

@ -18,8 +18,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
yes: boolean;
@ -27,7 +28,7 @@ interface FlagsDef {
}
interface ArgsDef {
name: string;
application: string;
}
export default class AppRmCmd extends Command {
@ -37,21 +38,19 @@ export default class AppRmCmd extends Command {
Permanently remove a balena application.
The --yes option may be used to avoid interactive confirmation.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena app rm MyApp',
'$ balena app rm MyApp --yes',
'$ balena app rm myorg/myapp',
];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
];
public static args = [ca.applicationRequired];
public static usage = 'app rm <name>';
public static usage = 'app rm <application>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes,
@ -65,15 +64,20 @@ export default class AppRmCmd extends Command {
AppRmCmd,
);
const patterns = await import('../../utils/patterns');
const { confirm } = await import('../../utils/patterns');
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
// Confirm
await patterns.confirm(
await confirm(
options.yes ?? false,
`Are you sure you want to delete application ${params.name}?`,
`Are you sure you want to delete application ${params.application}?`,
);
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const application = await getApplication(balena, params.application);
// Remove
await getBalenaSdk().models.application.remove(tryAsInteger(params.name));
await balena.models.application.remove(application.id);
}
}

View File

@ -19,7 +19,6 @@ import { flags } from '@oclif/command';
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 ApplicationWithDeviceType {
device_count?: number;
@ -28,7 +27,7 @@ interface ExtendedApplication extends ApplicationWithDeviceType {
interface FlagsDef {
help: void;
verbose?: boolean;
verbose: boolean;
}
export default class AppsCmd extends Command {
@ -38,8 +37,9 @@ export default class AppsCmd extends Command {
list all your balena applications.
For detailed information on a particular application,
use \`balena app <name> instead\`.
`;
use \`balena app <application>\` instead.
`;
public static examples = ['$ balena apps'];
public static usage = 'apps';
@ -47,10 +47,9 @@ export default class AppsCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
verbose: flags.boolean({
default: false,
char: 'v',
description: isV12()
? 'No-op since release v12.0.0'
: 'add extra columns in the tabular output (SLUG)',
description: 'No-op since release v12.0.0',
}),
};
@ -58,7 +57,7 @@ export default class AppsCmd extends Command {
public static primary = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(AppsCmd);
this.parse<FlagsDef, {}>(AppsCmd);
const balena = getBalenaSdk();
@ -87,7 +86,7 @@ export default class AppsCmd extends Command {
getVisuals().table.horizontal(applications, [
'id',
'app_name',
options.verbose || isV12() ? 'slug' : '',
'slug',
'device_type',
'online_devices',
'device_count',

View File

@ -19,6 +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 { applicationIdInfo } from '../../utils/messages';
import type { PineDeferred } from 'balena-sdk';
interface FlagsDef {
@ -51,7 +52,9 @@ export default class ConfigGenerateCmd extends Command {
that will be asked for the relevant device type.
In case that you want to configure an image for an application with mixed device types,
you can pass the --device-type argument along with --app to specify the target device type.
you can pass the --deviceType argument along with --application to specify the target device type.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
@ -60,7 +63,8 @@ export default class ConfigGenerateCmd extends Command {
'$ balena config generate --device 7cf02a6 --version 2.12.7 --device-api-key <existingDeviceKey>',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7',
'$ balena config generate --app MyApp --version 2.12.7 --device-type fincm3',
'$ balena config generate --app myorg/myapp --version 2.12.7',
'$ balena config generate --app MyApp --version 2.12.7 --deviceType fincm3',
'$ balena config generate --app MyApp --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7 --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1',
];
@ -72,15 +76,8 @@ export default class ConfigGenerateCmd extends Command {
description: 'a balenaOS version',
required: true,
}),
application: flags.string({
description: 'application name',
char: 'a',
exclusive: ['app', 'device'],
}),
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device'],
}),
application: { ...cf.application, exclusive: ['app', 'device'] },
app: { ...cf.app, exclusive: ['application', 'device'] },
device: flags.string({
description: 'device uuid',
char: 'd',
@ -154,6 +151,7 @@ export default class ConfigGenerateCmd extends Command {
};
resourceDeviceType = device.is_of__device_type[0].slug;
} else {
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
application = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
@ -227,17 +225,8 @@ export default class ConfigGenerateCmd extends Command {
$ balena help config generate
`;
protected readonly deviceTypeNotAllowedMessage = stripIndent`
Specifying a different device type is only supported when
generating a config for an application:
* An application, with --app <appname>
* A specific device type, with --device-type <deviceTypeSlug>
See the help page for examples:
$ balena help config generate
`;
protected readonly deviceTypeNotAllowedMessage =
'The --deviceType option can only be used alongside the --application option';
protected async validateOptions(options: FlagsDef) {
const { ExpectedError } = await import('../../errors');

View File

@ -20,22 +20,30 @@ import type { ImageDescriptor } from 'resin-compose-parse';
import Command from '../command';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy';
import { getBalenaSdk, getChalk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import * as compose from '../utils/compose';
import type {
BuiltImage,
ComposeCliFlags,
ComposeOpts,
Release as ComposeReleaseInfo,
} from '../utils/compose-types';
import type { DockerCliFlags } from '../utils/docker';
import {
applyReleaseTagKeysAndValues,
buildProject,
composeCliFlags,
isBuildConfig,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
import { dockerCliFlags } from '../utils/docker';
import type { Application, ApplicationType, DeviceType } from 'balena-sdk';
import type {
Application,
ApplicationType,
DeviceType,
Release,
} from 'balena-sdk';
interface ApplicationWithArch extends Application {
arch: string;
@ -45,6 +53,7 @@ interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string;
build: boolean;
nologupload: boolean;
'release-tag'?: string[];
help: void;
}
@ -85,6 +94,7 @@ ${dockerignoreHelp}
'$ balena deploy myApp',
'$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myApp myApp/myImage',
'$ balena deploy myApp myApp/myImage --release-tag key1 "" key2 "value2 with spaces"',
];
public static args = [
@ -115,6 +125,14 @@ ${dockerignoreHelp}
description:
"don't upload build logs to the dashboard with image (if building)",
}),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the image deployment is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
@ -151,6 +169,10 @@ ${dockerignoreHelp}
'../utils/compose_ts'
);
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
if (image) {
options['registry-secrets'] = await getRegistrySecrets(
sdk,
@ -180,7 +202,7 @@ ${dockerignoreHelp}
compose.generateOpts(options),
]);
await this.deployProject(docker, logger, composeOpts, {
const release = await this.deployProject(docker, logger, composeOpts, {
app,
appName, // may be prefixed by 'owner/', unlike app.app_name
image,
@ -189,6 +211,12 @@ ${dockerignoreHelp}
buildEmulated: !!options.emulated,
buildOpts,
});
await applyReleaseTagKeysAndValues(
sdk,
release.id,
releaseTagKeys,
releaseTagValues,
);
}
async deployProject(
@ -286,7 +314,7 @@ ${dockerignoreHelp}
},
);
let release;
let release: Release | ComposeReleaseInfo['release'];
if (appType?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy');
@ -344,6 +372,7 @@ ${dockerignoreHelp}
console.log();
console.log(doodles.getDoodle()); // Show charlie
console.log();
return release;
} catch (err) {
logger.logError('Deploy failed');
throw err;

View File

@ -0,0 +1,87 @@
/**
* @license
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
interface FlagsDef {
yes: boolean;
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceDeactivateCmd extends Command {
public static description = stripIndent`
Deactivate a device.
Deactivate a device.
Note this command asks for confirmation interactively.
You can avoid this by passing the \`--yes\` option.
`;
public static examples = [
'$ balena device deactivate 7cf02a6',
'$ balena device deactivate 7cf02a6 --yes',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'the UUID of the device to be deactivated',
required: true,
},
];
public static usage = 'device deactivate <uuid>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes,
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceDeactivateCmd,
);
const balena = getBalenaSdk();
const patterns = await import('../../utils/patterns');
const uuid = params.uuid;
const deactivationWarning = `
Warning! Deactivating a device will charge a fee equivalent to the
normal monthly cost for the device (e.g. $1 for an essentials device);
the device will not be charged again until it comes online.
`;
const warning = `Are you sure you want to deactivate device ${uuid} ?`;
console.error(deactivationWarning);
// Confirm
await patterns.confirm(options.yes, warning);
// Deactivate
await balena.models.device.deactivate(uuid);
}
}

View File

@ -96,6 +96,7 @@ export default class DeviceCmd extends Command {
'os_version',
'memory_usage',
'memory_total',
'public_address',
'storage_block_device',
'storage_usage',
'storage_total',
@ -168,6 +169,7 @@ export default class DeviceCmd extends Command {
'status',
'is_online',
'ip_address',
'public_address',
'mac_address',
'application_name',
'last_seen',

View File

@ -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 { applicationIdInfo } from '../../utils/messages';
import { runCommand } from '../../utils/helpers';
interface FlagsDef {
@ -34,17 +35,21 @@ interface FlagsDef {
export default class DeviceInitCmd extends Command {
public static description = stripIndent`
Initialise a device with balenaOS.
Initialize a device with balenaOS.
Initialise a device by downloading the OS image of a certain application
Initialize a device by downloading the OS image of a certain application
and writing it to an SD Card.
Note, if the application option is omitted it will be prompted
for interactively.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena device init',
'$ balena device init --application MyApp',
'$ balena device init -a myorg/myapp',
];
public static usage = 'device init';
@ -98,7 +103,7 @@ export default class DeviceInitCmd extends Command {
const application = (await getApplication(
balena,
options['application'] ||
(await (await import('../../utils/patterns')).selectApplication()),
(await (await import('../../utils/patterns')).selectApplication()).id,
{
$expand: {
is_for__device_type: {

View File

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

View File

@ -20,9 +20,8 @@ import type { IArg } from '@oclif/parser/lib/args';
import type { Application, BalenaSDK } from 'balena-sdk';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
import { ExpectedError } from '../../errors';
interface ExtendedDevice extends DeviceWithDeviceType {
@ -47,11 +46,15 @@ export default class DeviceMoveCmd extends Command {
Note, if the application option is omitted it will be prompted
for interactively.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena device move 7cf02a6',
'$ balena device move 7cf02a6,dc39e52',
'$ balena device move 7cf02a6 --application MyNewApp',
'$ balena device move 7cf02a6 -a myorg/mynewapp',
];
public static args: Array<IArg<any>> = [
@ -80,6 +83,9 @@ export default class DeviceMoveCmd extends Command {
const balena = getBalenaSdk();
const { tryAsInteger } = await import('../../utils/validation');
const { expandForAppName } = await import('../../utils/helpers');
options.application = options.application || options.app;
delete options.app;
@ -106,16 +112,21 @@ export default class DeviceMoveCmd extends Command {
: 'N/a';
}
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const { getApplication } = await import('../../utils/sdk');
// Get destination application
const application =
options.application ||
(await this.interactivelySelectApplication(balena, devices));
const application = options.application
? await getApplication(balena, options.application)
: await this.interactivelySelectApplication(balena, devices);
// Move each device
for (const uuid of deviceIds) {
try {
await balena.models.device.move(uuid, tryAsInteger(application));
console.info(`${uuid} was moved to ${application}`);
await balena.models.device.move(uuid, application.id);
console.info(
`Device ${uuid} was moved to application ${application.slug}`,
);
} catch (err) {
console.info(`${err.message}, uuid: ${uuid}`);
process.exitCode = 1;

View File

@ -19,7 +19,9 @@ 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 * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
uuid?: string;
@ -35,19 +37,17 @@ export default class DeviceRegisterCmd extends Command {
Register a device.
Register a device to an application.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena device register MyApp',
'$ balena device register MyApp --uuid <uuid>',
'$ balena device register myorg/myapp --uuid <uuid>',
];
public static args: Array<IArg<any>> = [
{
name: 'application',
description: 'the name or id of application to register device with',
required: true,
},
];
public static args: Array<IArg<any>> = [ca.applicationRequired];
public static usage = 'device register <application>';

View File

@ -20,7 +20,7 @@ import Command from '../../command';
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 { applicationIdInfo, jsonInfo } from '../../utils/messages';
import type { Application } from 'balena-sdk';
interface ExtendedDevice extends DeviceWithDeviceType {
@ -44,17 +44,16 @@ export default class DevicesCmd extends Command {
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/).
${applicationIdInfo.split('\n').join('\n\t\t')}
${jsonInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena devices',
'$ balena devices --application MyApp',
'$ balena devices --app MyApp',
'$ balena devices -a MyApp',
'$ balena devices -a myorg/myapp',
];
public static usage = 'devices';
@ -62,11 +61,8 @@ export default class DevicesCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
application: cf.application,
app: cf.app,
json: cf.json,
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
};
public static primary = true;
@ -85,8 +81,10 @@ export default class DevicesCmd extends Command {
let devices;
if (options.application != null) {
const { getApplication } = await import('../../utils/sdk');
const application = await getApplication(balena, options.application);
devices = (await balena.models.device.getAllByApplication(
tryAsInteger(options.application),
application.id,
expandForAppName,
)) as ExtendedDevice[];
} else {
@ -101,7 +99,7 @@ export default class DevicesCmd extends Command {
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]?.app_name || null;
device.uuid = device.uuid.slice(0, 7);
device.uuid = options.json ? device.uuid : device.uuid.slice(0, 7);
device.device_type = device.is_of__device_type?.[0]?.slug || null;
return device;

View File

@ -18,13 +18,13 @@
import { flags } from '@oclif/command';
import type * as BalenaSdk from 'balena-sdk';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
application?: string; // application name
application?: string;
device?: string; // device UUID
help: void;
quiet: boolean;
@ -63,10 +63,14 @@ export default class EnvAddCmd extends Command {
therefore the --service option cannot be used when the variable name starts
with a reserved prefix. When defining custom application variables, please
avoid the reserved prefixes.
`;
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena env add TERM --application MyApp',
'$ balena env add EDITOR vim --application MyApp',
'$ balena env add EDITOR vim -a myorg/myapp',
'$ balena env add EDITOR vim --application MyApp,MyApp2',
'$ balena env add EDITOR vim --application MyApp --service MyService',
'$ balena env add EDITOR vim --application MyApp,MyApp2 --service MyService,MyService2',
@ -93,8 +97,8 @@ export default class EnvAddCmd extends Command {
public static usage = 'env add <name> [value]';
public static flags: flags.Input<FlagsDef> = {
application: { exclusive: ['device'], ...cf.application },
device: { exclusive: ['application'], ...cf.device },
application: { ...cf.application, exclusive: ['device'] },
device: { ...cf.device, exclusive: ['application'] },
help: cf.help,
quiet: cf.quiet,
service: cf.service,
@ -108,7 +112,7 @@ export default class EnvAddCmd extends Command {
if (!options.application && !options.device) {
throw new ExpectedError(
'Either the --application or the --device option must always be used',
'Either the --application or the --device option must be specified',
);
}

View File

@ -18,16 +18,14 @@ import { flags } from '@oclif/command';
import type * as SDK from 'balena-sdk';
import * as _ from 'lodash';
import Command from '../command';
import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { CommandHelp } from '../utils/oclif-utils';
import { isV12 } from '../utils/version';
import { applicationIdInfo } from '../utils/messages';
import { isV13 } from '../utils/version';
interface FlagsDef {
all?: boolean; // whether to include application-wide, device-wide variables //TODO: REMOVE
application?: string; // application name
application?: string;
config: boolean;
device?: string; // device UUID
json: boolean;
@ -57,8 +55,7 @@ interface ServiceEnvironmentVariableInfo
}
export default class EnvsCmd extends Command {
public static description = isV12()
? stripIndent`
public static description = stripIndent`
List the environment or config variables of an application, device or service.
List the environment or configuration variables of an application, device or
@ -91,99 +88,47 @@ export default class EnvsCmd extends Command {
application linked to the device is no longer accessible by the current user
(for example, in case the current user has been removed from the application
by its owner).
`
: stripIndent`
List the environment or config variables of an application, device or service.
List the environment or configuration variables of an application, device or
service, as selected by the respective command-line options. (A service is
an application container in a "microservices" application.)
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
The --config option is used to list "configuration variables" that control
balena platform features, as opposed to custom environment variables defined
by the user. The --config and the --service options are mutually exclusive
because configuration variables cannot be set for specific services.
public static examples = [
'$ balena envs --application MyApp',
'$ balena envs --application myorg/myapp',
'$ balena envs --application MyApp --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --json',
'$ balena envs --device 7cf02a6 --config --json',
'$ balena envs --device 7cf02a6 --service MyService',
];
The --all option is used to include application-wide (fleet), device-wide
(multiple services on a device) and service-specific variables that apply to
the selected application, device or service. It can be thought of as including
"inherited" variables: for example, a service inherits device-wide variables,
and a device inherits application-wide variables. Variables are still filtered
out by type with the --config option, such that configuration and non-
configuration variables are never listed together.
When the --all option is used, the printed output may include DEVICE and/or
SERVICE columns to distinguish between application-wide, device-specific and
service-specific variables. An asterisk in these columns indicates that the
variable applies to "all devices" or "all services".
The --json option is recommended when scripting the output of this command,
because the JSON format is less likely to change and it better represents data
types like lists and empty strings. The 'jq' utility may be helpful in shell
scripts (https://stedolan.github.io/jq/manual/). When --json is used, an empty
JSON array ([]) is printed instead of an error message when no variables exist
for the given query. When querying variables for a device, note that the
application name may be null in JSON output (or 'N/A' in tabular output) if the
application linked to the device is no longer accessible by the current user
(for example, in case the current user has been removed from the application
by its owner).
`;
public static examples = isV12()
? [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --json',
'$ balena envs --device 7cf02a6 --config --json',
'$ balena envs --device 7cf02a6 --service MyService',
]
: [
'$ balena envs --application MyApp',
'$ balena envs --application MyApp --all --json',
'$ balena envs --application MyApp --service MyService',
'$ balena envs --application MyApp --all --service MyService',
'$ balena envs --application MyApp --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --all --json',
'$ balena envs --device 7cf02a6 --config --all --json',
'$ balena envs --device 7cf02a6 --all --service MyService',
];
public static usage = (
'envs ' + new CommandHelp({ args: EnvsCmd.args }).defaultUsage()
).trim();
public static usage = 'envs';
public static flags: flags.Input<FlagsDef> = {
...(isV12()
? {
...(isV13()
? {}
: {
all: flags.boolean({
default: false,
description: stripIndent`
No-op since balena CLI v12.0.0.`,
hidden: true,
}),
}
: {
all: flags.boolean({
description: stripIndent`
include app-wide, device-wide variables that apply to the selected device or service.
Variables are still filtered out by type with the --config option.`,
}),
}),
application: { exclusive: ['device'], ...cf.application },
config: flags.boolean({
default: false,
char: 'c',
description: 'show configuration variables only',
exclusive: ['service'],
}),
device: { exclusive: ['application'], ...cf.device },
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
json: cf.json,
verbose: cf.verbose,
service: { exclusive: ['config'], ...cf.service },
};
@ -192,8 +137,6 @@ export default class EnvsCmd extends Command {
const { flags: options } = this.parse<FlagsDef, {}>(EnvsCmd);
const variables: EnvironmentVariableInfo[] = [];
options.all = options.all || isV12();
await Command.checkLoggedIn();
if (!options.application && !options.device) {
@ -202,7 +145,7 @@ export default class EnvsCmd extends Command {
const balena = getBalenaSdk();
let appName = options.application;
let appNameOrSlug = options.application;
let fullUUID: string | undefined; // as oppposed to the short, 7-char UUID
if (options.device) {
@ -215,18 +158,16 @@ export default class EnvsCmd extends Command {
);
fullUUID = device.uuid;
if (app) {
appName = app.app_name;
appNameOrSlug = app.app_name;
}
}
if (appName && options.service) {
await validateServiceName(balena, options.service, appName);
}
if (options.application || options.all) {
variables.push(...(await getAppVars(balena, appName, options)));
if (appNameOrSlug && options.service) {
await validateServiceName(balena, options.service, appNameOrSlug);
}
variables.push(...(await getAppVars(balena, appNameOrSlug, options)));
if (fullUUID) {
variables.push(
...(await getDeviceVars(balena, fullUUID, appName, options)),
...(await getDeviceVars(balena, fullUUID, appNameOrSlug, options)),
);
}
if (!options.json && variables.length === 0) {
@ -247,20 +188,18 @@ export default class EnvsCmd extends Command {
) {
const fields = ['id', 'name', 'value'];
if (options.all) {
// Replace undefined app names with 'N/A' or null
varArray = varArray.map((i: EnvironmentVariableInfo) => {
i.appName = i.appName || (options.json ? null : 'N/A');
return i;
});
// Replace undefined app names with 'N/A' or null
varArray = varArray.map((i: EnvironmentVariableInfo) => {
i.appName = i.appName || (options.json ? null : 'N/A');
return i;
});
fields.push(options.json ? 'appName' : 'appName => APPLICATION');
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
fields.push(options.json ? 'appName' : 'appName => APPLICATION');
if (options.device) {
fields.push(options.json ? 'deviceUUID' : 'deviceUUID => DEVICE');
}
if (!options.config) {
fields.push(options.json ? 'serviceName' : 'serviceName => SERVICE');
}
if (options.json) {
@ -302,21 +241,19 @@ async function validateServiceName(
*/
async function getAppVars(
sdk: SDK.BalenaSDK,
appName: string | undefined,
appNameOrSlug: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const appVars: EnvironmentVariableInfo[] = [];
if (!appName) {
if (!appNameOrSlug) {
return appVars;
}
if (options.config || options.all || !options.service) {
const vars = await sdk.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(appName);
fillInInfoFields(vars, appName);
appVars.push(...vars);
}
if (!options.config && (options.service || options.all)) {
const vars = await sdk.models.application[
options.config ? 'configVar' : 'envVar'
].getAllByApplication(appNameOrSlug);
fillInInfoFields(vars, appNameOrSlug);
appVars.push(...vars);
if (!options.config) {
const pineOpts: SDK.PineOptions<SDK.ServiceEnvironmentVariable> = {
$expand: {
service: {},
@ -330,10 +267,10 @@ async function getAppVars(
};
}
const serviceVars = await sdk.models.service.var.getAllByApplication(
appName,
appNameOrSlug,
pineOpts,
);
fillInInfoFields(serviceVars, appName);
fillInInfoFields(serviceVars, appNameOrSlug);
appVars.push(...serviceVars);
}
return appVars;
@ -346,7 +283,7 @@ async function getAppVars(
async function getDeviceVars(
sdk: SDK.BalenaSDK,
fullUUID: string,
appName: string | undefined,
appNameOrSlug: string | undefined,
options: FlagsDef,
): Promise<EnvironmentVariableInfo[]> {
const printedUUID = options.json ? fullUUID : options.device!;
@ -355,38 +292,35 @@ async function getDeviceVars(
const deviceConfigVars = await sdk.models.device.configVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceConfigVars, appName, printedUUID);
fillInInfoFields(deviceConfigVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceConfigVars);
} else {
if (options.service || options.all) {
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
$expand: {
service_install: {
$expand: 'installs__service',
},
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
$expand: {
service_install: {
$expand: 'installs__service',
},
},
};
if (options.service) {
pineOpts.$filter = {
service_install: {
installs__service: { service_name: options.service },
},
};
if (options.service) {
pineOpts.$filter = {
service_install: {
installs__service: { service_name: options.service },
},
};
}
const deviceServiceVars = await sdk.models.device.serviceVar.getAllByDevice(
fullUUID,
pineOpts,
);
fillInInfoFields(deviceServiceVars, appName, printedUUID);
deviceVars.push(...deviceServiceVars);
}
if (!options.service || options.all) {
const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceEnvVars, appName, printedUUID);
deviceVars.push(...deviceEnvVars);
}
const deviceServiceVars = await sdk.models.device.serviceVar.getAllByDevice(
fullUUID,
pineOpts,
);
fillInInfoFields(deviceServiceVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceServiceVars);
const deviceEnvVars = await sdk.models.device.envVar.getAllByDevice(
fullUUID,
);
fillInInfoFields(deviceEnvVars, appNameOrSlug, printedUUID);
deviceVars.push(...deviceEnvVars);
}
return deviceVars;
}
@ -401,7 +335,7 @@ function fillInInfoFields(
| EnvironmentVariableInfo[]
| DeviceServiceEnvironmentVariableInfo[]
| ServiceEnvironmentVariableInfo[],
appName?: string,
appNameOrSlug?: string,
deviceUUID?: string,
) {
for (const envVar of varArray) {
@ -413,7 +347,7 @@ function fillInInfoFields(
envVar.serviceName = ((envVar.service_install as SDK.ServiceInstall[])[0]
?.installs__service as SDK.Service[])[0]?.service_name;
}
envVar.appName = appName;
envVar.appName = appNameOrSlug;
envVar.serviceName = envVar.serviceName || '*';
envVar.deviceUUID = deviceUUID || '*';
}

View File

@ -1,55 +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 Command from '../../command';
import { stripIndent } from '../../utils/lazy';
// 'Internal' commands are called during the execution of other commands.
// `scandevices` is called during by `join`,`leave'.
// TODO: These should be refactored to modules/functions, and removed
// See previous `internal sudo` refactor:
// - https://github.com/balena-io/balena-cli/pull/1455/files
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308357
// - https://github.com/balena-io/balena-cli/pull/1455#discussion_r334308526
export default class ScandevicesCmd extends Command {
public static description = stripIndent`
Scan for local balena-enabled devices and show a picker to choose one.
Don't use this command directly!
`;
public static usage = 'internal scandevices';
public static root = true;
public static hidden = true;
public async run() {
const { forms } = await import('balena-sync');
try {
const hostnameOrIp = await forms.selectLocalBalenaOsDevice();
return console.error(`==> Selected device: ${hostnameOrIp}`);
} catch (e) {
if (e.message.toLowerCase().includes('could not find any')) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(e);
} else {
throw e;
}
}
}
}

View File

@ -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 { applicationIdInfo } from '../utils/messages';
import { parseAsLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
@ -47,14 +48,17 @@ export default class JoinCmd extends Command {
If you don't specify a device hostname or IP, this command will automatically
scan the local network for balenaOS devices and prompt you to select one
from an interactive picker. This requires root privileges. Likewise, if
the application flag is not provided then a picker will be shown.
from an interactive picker. This may require administrator/root privileges.
Likewise, if the application flag is not provided then a picker will be shown.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena join',
'$ balena join balena.local',
'$ balena join balena.local --application MyApp',
'$ balena join balena.local -a myorg/myapp',
'$ balena join 192.168.1.25',
'$ balena join 192.168.1.25 --application MyApp',
];
@ -71,10 +75,7 @@ export default class JoinCmd extends Command {
public static usage = 'join [deviceIpOrHostname]';
public static flags: flags.Input<FlagsDef> = {
application: {
description: 'the name of the application the device should join',
...cf.application,
},
application: cf.application,
pollInterval: flags.integer({
description: 'the interval in minutes to check for updates',
char: 'i',

View File

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

View File

@ -72,16 +72,19 @@ export default class LoginCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
web: flags.boolean({
default: false,
char: 'w',
description: 'web-based login',
exclusive: ['token', 'credentials'],
}),
token: flags.boolean({
default: false,
char: 't',
description: 'session token or API key',
exclusive: ['web', 'credentials'],
}),
credentials: flags.boolean({
default: false,
char: 'c',
description: 'credential-based login',
exclusive: ['web', 'token'],

View File

@ -23,6 +23,7 @@ import { LogMessage } from 'balena-sdk';
import { IArg } from '@oclif/parser/lib/args';
interface FlagsDef {
'max-retry'?: number;
tail?: boolean;
service?: string[];
system?: boolean;
@ -33,6 +34,8 @@ interface ArgsDef {
device: string;
}
const MAX_RETRY = 1000;
export default class LogsCmd extends Command {
public static description = stripIndent`
Show device logs.
@ -75,7 +78,13 @@ export default class LogsCmd extends Command {
public static usage = 'logs <device>';
public static flags: flags.Input<FlagsDef> = {
'max-retry': flags.integer({
description: stripIndent`
Maximum number of reconnection attempts on "connection lost" errors
(use 0 to disable auto reconnection).`,
}),
tail: flags.boolean({
default: false,
description: 'continuously stream output',
char: 't',
}),
@ -87,6 +96,7 @@ export default class LogsCmd extends Command {
multiple: true,
}),
system: flags.boolean({
default: false,
description:
'Only show system logs. This can be used in combination with --service.',
char: 'S',
@ -103,7 +113,7 @@ export default class LogsCmd extends Command {
const balena = getBalenaSdk();
const { serviceIdToName } = await import('../utils/cloud');
const { displayDeviceLogs, displayLogObject } = await import(
const { connectAndDisplayDeviceLogs, displayLogObject } = await import(
'../utils/device/logs'
);
const { validateIPAddress, validateDotLocalUrl } = await import(
@ -151,13 +161,13 @@ export default class LogsCmd extends Command {
}
logger.logDebug('Streaming logs');
const logStream = await deviceApi.getLogStream();
await displayDeviceLogs(
logStream,
await connectAndDisplayDeviceLogs({
deviceApi,
logger,
options.system || false,
options.service,
);
system: options.system || false,
filterServices: options.service,
maxAttempts: 1 + (options['max-retry'] ?? MAX_RETRY),
});
} else {
// Logs from cloud
await Command.checkLoggedIn();

56
lib/commands/orgs.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
interface FlagsDef {
help: void;
}
export default class OrgsCmd extends Command {
public static description = stripIndent`
List all organizations.
list all the organizations that you are a member of.
`;
public static examples = ['$ balena orgs'];
public static usage = 'orgs';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public async run() {
this.parse<FlagsDef, {}>(OrgsCmd);
const { getOwnOrganizations } = await import('../utils/sdk');
// Get organizations
const organizations = await getOwnOrganizations(getBalenaSdk());
// Display
console.log(
getVisuals().table.horizontal(organizations, ['name', 'handle']),
);
}
}

View File

@ -18,20 +18,19 @@
import { flags } from '@oclif/command';
import type * as BalenaSdk from 'balena-sdk';
import * as _ from 'lodash';
import * as path from 'path';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
const BOOT_PARTITION = 1;
const CONNECTIONS_FOLDER = '/system-connections';
interface FlagsDef {
advanced?: boolean;
app?: string;
application?: string;
app?: string;
config?: string;
'config-app-update-poll-interval'?: number;
'config-network'?: string;
@ -88,15 +87,19 @@ export default class OsConfigureCmd extends Command {
${deviceApiKeyDeprecationMsg.split('\n').join('\n\t\t')}
${applicationIdInfo.split('\n').join('\n\t\t')}
Note: This command is currently not supported on Windows natively. Windows users
are advised to install the Windows Subsystem for Linux (WSL) with Ubuntu, and use
the Linux release of the balena CLI:
https://docs.microsoft.com/en-us/windows/wsl/about
`;
public static examples = [
'$ balena os configure ../path/rpi3.img --device 7cf02a6',
'$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>',
'$ balena os configure ../path/rpi3.img --app MyApp',
'$ balena os configure ../path/rpi3.img -a myorg/myapp',
'$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3',
'$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3 --config myWifiConfig.json',
@ -118,11 +121,8 @@ export default class OsConfigureCmd extends Command {
description:
'ask advanced configuration questions (when in interactive mode)',
}),
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device'],
}),
application: { exclusive: ['app', 'device'], ...cf.application },
application: { ...cf.application, exclusive: ['app', 'device'] },
app: { ...cf.app, exclusive: ['application', 'device'] },
config: flags.string({
description:
'path to a pre-generated config.json file to be injected in the OS image',
@ -155,7 +155,6 @@ export default class OsConfigureCmd extends Command {
description:
'This option will set the device name when the device provisions',
}),
help: cf.help,
version: flags.string({
description: 'balenaOS version, for example "2.32.0" or "2.44.0+rev1"',
}),
@ -166,6 +165,7 @@ export default class OsConfigureCmd extends Command {
description:
"paths to local files to place into the 'system-connections' directory",
}),
help: cf.help,
};
public async run() {
@ -174,7 +174,7 @@ export default class OsConfigureCmd extends Command {
);
// Prefer options.application over options.app
options.application = options.application || options.app;
options.app = undefined;
delete options.app;
await validateOptions(options);
@ -266,6 +266,8 @@ export default class OsConfigureCmd extends Command {
);
if (options['system-connection']) {
const path = await import('path');
const files = await Promise.all(
options['system-connection'].map(async (filePath) => {
const content = await fs.readFile(filePath, 'utf8');

View File

@ -17,12 +17,14 @@
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import {
getBalenaSdk,
getCliForm,
getVisuals,
stripIndent,
} from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
import type { DockerConnectionCliFlags } from '../utils/docker';
import { dockerConnectionCliFlags } from '../utils/docker';
import * as _ from 'lodash';
@ -43,6 +45,7 @@ interface FlagsDef extends DockerConnectionCliFlags {
'splash-image'?: string;
'dont-check-arch': boolean;
'pin-device-to-release': boolean;
'additional-space'?: number;
'add-certificate'?: string[];
help: void;
}
@ -62,13 +65,16 @@ export default class PreloadCmd extends Command {
When the device boots, it will not need to download the application, as it was
preloaded.
${applicationIdInfo.split('\n').join('\n\t\t')}
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 --app MyApp --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0',
'$ balena preload balena.img --app myorg/myapp --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png',
'$ balena preload balena.img',
];
@ -83,10 +89,8 @@ export default class PreloadCmd extends Command {
public static usage = 'preload <image>';
public static flags: flags.Input<FlagsDef> = {
app: flags.string({
description: 'name of the application to preload',
char: 'a',
}),
// TODO: Replace with application/a in #v13?
app: cf.application,
commit: flags.string({
description: `\
The commit hash for a specific application release to preload, use "current" to specify the current
@ -100,14 +104,21 @@ manually pinned using https://github.com/balena-io-projects/staged-releases .\
char: 's',
}),
'dont-check-arch': flags.boolean({
default: false,
description:
'disables check for matching architecture in image and application',
}),
'pin-device-to-release': flags.boolean({
default: false,
description:
'pin the preloaded device to the preloaded release on provision',
char: 'p',
}),
'additional-space': flags.integer({
description:
'expand the image by this amount of bytes instead of automatically estimating the required amount',
parse: (x) => parseAsInteger(x, 'additional-space'),
}),
'add-certificate': flags.string({
description: `\
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
@ -155,6 +166,18 @@ Can be repeated to add multiple certificates.\
);
}
// balena-preload currently does not work with numerical app IDs
// Load app here, and use app slug from hereon
if (options.app && !options.app.includes('/')) {
// Disambiguate application (if is a number, it could either be an ID or a numerical name)
const { getApplication } = await import('../utils/sdk');
const application = await getApplication(balena, options.app);
if (!application) {
throw new ExpectedError(`Application not found: ${options.app}`);
}
options.app = application.slug;
}
const progressBars: {
[key: string]: ReturnType<typeof getVisuals>['Progress'];
} = {};
@ -192,13 +215,14 @@ Can be repeated to add multiple certificates.\
const appId = options.app;
const splashImage = options['splash-image'];
const additionalSpace = options['additional-space'];
const dontCheckArch = options['dont-check-arch'] || false;
const pinDevice = options['pin-device-to-release'] || false;
if (dontCheckArch && !appId) {
throw new ExpectedError(
'You need to specify an app id if you disable the architecture check.',
'You need to specify an application if you disable the architecture check.',
);
}
@ -223,6 +247,7 @@ Can be repeated to add multiple certificates.\
dontCheckArch,
pinDevice,
certificates,
additionalSpace,
);
let gotSignal = false;

View File

@ -20,44 +20,52 @@ 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, Application, Organization } from 'balena-sdk';
import type { BalenaSDK } from 'balena-sdk';
import { ExpectedError, instanceOf } from '../errors';
import { isV13 } from '../utils/version';
import { RegistrySecrets } from 'resin-multibuild';
import { lowercaseIfSlug } from '../utils/normalization';
import {
applyReleaseTagKeysAndValues,
parseReleaseTagKeysAndValues,
} from '../utils/compose_ts';
enum BuildTarget {
Cloud,
Device,
}
interface FlagsDef {
source?: string;
emulated: boolean;
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache?: boolean;
pull?: boolean;
'noparent-check'?: boolean;
'registry-secrets'?: string;
gitignore?: boolean;
nogitignore?: boolean;
nolive?: boolean;
detached?: boolean;
service?: string[];
system?: boolean;
env?: string[];
'convert-eol'?: boolean;
'noconvert-eol'?: boolean;
'multi-dockerignore'?: boolean;
help: void;
}
interface ArgsDef {
applicationOrDevice: string;
}
interface FlagsDef {
source: string;
emulated: boolean;
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache: boolean;
pull: boolean;
'noparent-check': boolean;
'registry-secrets'?: string;
gitignore?: boolean;
nogitignore?: boolean;
nolive: boolean;
detached: boolean;
service?: string[];
system: boolean;
env?: string[];
'convert-eol'?: boolean;
'noconvert-eol': boolean;
'multi-dockerignore': boolean;
'release-tag'?: string[];
help: void;
}
export default class PushCmd extends Command {
public static description = stripIndent`
Start a remote build on the balenaCloud build servers or a local mode device.
Start a build on the remote balenaCloud build servers, or a local mode device.
Start a build on the remote balenaCloud builders, or a local mode balena device.
Start a build on the remote balenaCloud build servers, or a local mode device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
@ -92,6 +100,8 @@ export default class PushCmd extends Command {
'$ balena push myApp',
'$ balena push myApp --source <source directory>',
'$ balena push myApp -s <source directory>',
'$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"',
'$ balena push myorg/myapp',
'',
'$ balena push 10.0.0.1',
'$ balena push 10.0.0.1 --source <source directory>',
@ -106,8 +116,10 @@ export default class PushCmd extends Command {
public static args = [
{
name: 'applicationOrDevice',
description: 'application name, or device address (for local pushes)',
description:
'application name or slug, or local device IP address or hostname',
required: true,
parse: lowercaseIfSlug,
},
];
@ -119,12 +131,15 @@ export default class PushCmd extends Command {
Source directory to be sent to balenaCloud or balenaOS device
(default: current working dir)`,
char: 's',
default: '.',
}),
emulated: flags.boolean({
description: stripIndent`
Don't use native ARM servers; force QEMU ARM emulation on Intel x86-64
servers during the image build (balenaCloud).`,
Don't use the faster, native balenaCloud ARM builders; force slower QEMU ARM
emulation on Intel x86-64 builders. This flag is sometimes used to investigate
suspected issues with the balenaCloud backend.`,
char: 'e',
default: false,
}),
dockerfile: flags.string({
description:
@ -139,15 +154,18 @@ export default class PushCmd extends Command {
updates), but the logs will not display the "Using cache" lines for each
build step of a Dockerfile.`,
char: 'c',
default: false,
}),
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.`,
default: false,
}),
'noparent-check': flags.boolean({
description: stripIndent`
Disable project validation check of 'docker-compose.yml' file in parent folder`,
default: false,
}),
'registry-secrets': flags.string({
description: stripIndent`
@ -163,6 +181,7 @@ export default class PushCmd extends Command {
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.`,
default: false,
}),
detached: flags.boolean({
description: stripIndent`
@ -171,6 +190,7 @@ export default class PushCmd extends Command {
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',
default: false,
}),
service: flags.string({
description: stripIndent`
@ -183,6 +203,7 @@ export default class PushCmd extends Command {
description: stripIndent`
Only show system logs. This can be used in combination with --service.
Only valid when pushing to a local mode device.`,
default: false,
}),
env: flags.string({
description: stripIndent`
@ -196,34 +217,56 @@ export default class PushCmd extends Command {
`,
multiple: true,
}),
'convert-eol': flags.boolean({
description: 'No-op and deprecated since balena CLI v12.0.0',
char: 'l',
hidden: true,
}),
...(isV13()
? {}
: {
'convert-eol': flags.boolean({
description: 'No-op and deprecated since balena CLI v12.0.0',
char: 'l',
hidden: true,
default: false,
}),
}),
'noconvert-eol': flags.boolean({
description: `Don't convert line endings from CRLF (Windows format) to LF (Unix format).`,
default: false,
}),
'multi-dockerignore': flags.boolean({
description:
'Have each service use its own .dockerignore file. See "balena help push".',
char: 'm',
default: false,
exclusive: ['gitignore'],
}),
nogitignore: flags.boolean({
description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
char: 'G',
hidden: true,
}),
...(isV13()
? {}
: {
nogitignore: flags.boolean({
description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
char: 'G',
hidden: true,
default: false,
}),
}),
gitignore: flags.boolean({
description: stripIndent`
Consider .gitignore files in addition to the .dockerignore file. This reverts
to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted.`,
Consider .gitignore files in addition to the .dockerignore file. This reverts
to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted.`,
char: 'g',
default: false,
exclusive: ['multi-dockerignore'],
}),
'release-tag': flags.string({
description: stripIndent`
Set release tags if the push to a cloud application is successful. Multiple
arguments may be provided, alternating tag keys and values (see examples).
Hint: Empty values may be specified with "" (bash, cmd.exe) or '""' (PowerShell).
`,
multiple: true,
exclusive: ['detached'],
}),
help: cf.help,
};
@ -234,186 +277,181 @@ export default class PushCmd extends Command {
PushCmd,
);
const logger = await Command.getLogger();
logger.logDebug(`Using build source directory: ${options.source} `);
const sdk = getBalenaSdk();
const { validateProjectDirectory } = await import('../utils/compose_ts');
const source = options.source || '.';
if (process.env.DEBUG) {
console.error(`[debug] Using ${source} as build source`);
}
const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
sdk,
{
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: source,
noParentCheck: options['noparent-check'],
projectPath: options.source,
registrySecretsPath: options['registry-secrets'],
},
);
const nogitignore = !options.gitignore;
const convertEol = !options['noconvert-eol'];
const appOrDevice = params.applicationOrDevice;
const buildTarget = await this.getBuildTarget(appOrDevice);
switch (buildTarget) {
switch (await this.getBuildTarget(params.applicationOrDevice)) {
case BuildTarget.Cloud:
const remote = await import('../utils/remote-build');
logger.logDebug(
`Pushing to cloud for application: ${params.applicationOrDevice}`,
);
// Check for invalid options
const localOnlyOptions = ['nolive', 'service', 'system', 'env'];
localOnlyOptions.forEach((opt) => {
// @ts-ignore : Not sure why typescript wont let me do this?
if (options[opt]) {
throw new ExpectedError(
`The --${opt} flag is only valid when pushing to a local mode device`,
);
}
});
const app = appOrDevice;
await Command.checkLoggedIn();
const [token, baseUrl, owner] = await Promise.all([
sdk.auth.getToken(),
sdk.settings.get('balenaUrl'),
this.getAppOwner(sdk, app),
]);
const opts = {
dockerfilePath,
emulated: options.emulated || false,
multiDockerignore: options['multi-dockerignore'] || false,
nocache: options.nocache || false,
registrySecrets,
headless: options.detached || false,
convertEol,
};
const args = {
app,
owner,
source,
auth: token,
baseUrl,
nogitignore,
await this.pushToCloud(
params.applicationOrDevice,
options,
sdk,
opts,
};
await remote.startRemoteBuild(args);
dockerfilePath,
registrySecrets,
);
break;
case BuildTarget.Device:
const deviceDeploy = await import('../utils/device/deploy');
const device = appOrDevice;
const servicesToDisplay = options.service;
// TODO: Support passing a different port
try {
await deviceDeploy.deployToDevice({
source,
deviceHost: device,
dockerfilePath,
registrySecrets,
multiDockerignore: options['multi-dockerignore'] || false,
nocache: options.nocache || false,
pull: options.pull || false,
nogitignore,
noParentCheck: options['noparent-check'] || false,
nolive: options.nolive || false,
detached: options.detached || false,
services: servicesToDisplay,
system: options.system || false,
env: options.env || [],
convertEol,
});
} catch (e) {
const { BuildError } = await import('../utils/device/errors');
if (instanceOf(e, BuildError)) {
throw new ExpectedError(e.toString());
} else {
throw e;
}
}
logger.logDebug(
`Pushing to local device: ${params.applicationOrDevice}`,
);
await this.pushToDevice(
params.applicationOrDevice,
options,
dockerfilePath,
registrySecrets,
);
break;
default:
throw new ExpectedError(stripIndent`
Build target not recognized. Please provide either an application name or
device IP address.`);
}
}
async getBuildTarget(appOrDevice: string): Promise<BuildTarget | null> {
const {
validateApplicationName,
validateDotLocalUrl,
validateIPAddress,
} = await import('../utils/validation');
protected async pushToCloud(
appNameOrSlug: string,
options: FlagsDef,
sdk: BalenaSDK,
dockerfilePath: string,
registrySecrets: RegistrySecrets,
) {
const remote = await import('../utils/remote-build');
const { getApplication } = await import('../utils/sdk');
// First try the application regex from the api
if (validateApplicationName(appOrDevice)) {
return BuildTarget.Cloud;
}
if (validateIPAddress(appOrDevice) || validateDotLocalUrl(appOrDevice)) {
return BuildTarget.Device;
}
return null;
}
async getAppOwner(sdk: BalenaSDK, appName: string) {
const _ = await import('lodash');
const applications = (await sdk.models.application.getAll({
$expand: {
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(
stripIndent`
No applications found with name: ${appName}.
This could mean that the application does not exist, or you do
not have the permissions required to access it.`,
);
}
if (applications.length === 1) {
return applications[0].organization[0].handle;
}
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app. We
// 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 = app.organization[0].handle;
return {
name: `${username}/${appName}`,
extra: username,
};
});
const { selectFromList } = await import('../utils/patterns');
const selected = await selectFromList(
`${entries.length} applications found with that name, please select the application you would like to push to`,
entries,
// Check for invalid options
const localOnlyOptions: Array<keyof FlagsDef> = [
'nolive',
'service',
'system',
'env',
];
this.checkInvalidOptions(
localOnlyOptions,
options,
'is only valid when pushing to a local mode device',
);
return selected.extra;
const { releaseTagKeys, releaseTagValues } = parseReleaseTagKeysAndValues(
options['release-tag'] ?? [],
);
await Command.checkLoggedIn();
const [token, baseUrl] = await Promise.all([
sdk.auth.getToken(),
sdk.settings.get('balenaUrl'),
]);
const application = await getApplication(sdk, appNameOrSlug, {
$select: ['app_name', 'slug'],
});
const opts = {
dockerfilePath,
emulated: options.emulated,
multiDockerignore: options['multi-dockerignore'],
nocache: options.nocache,
registrySecrets,
headless: options.detached,
convertEol: !options['noconvert-eol'],
};
const args = {
appSlug: application.slug,
source: options.source,
auth: token,
baseUrl,
nogitignore: !options.gitignore,
sdk,
opts,
};
const releaseId = await remote.startRemoteBuild(args);
if (releaseId) {
await applyReleaseTagKeysAndValues(
sdk,
releaseId,
releaseTagKeys,
releaseTagValues,
);
} else if (releaseTagKeys.length > 0) {
throw new Error(stripIndent`
A release ID could not be parsed out of the builder's output.
As a result, the release tags have not been set.`);
}
}
protected async pushToDevice(
localDeviceAddress: string,
options: FlagsDef,
dockerfilePath: string,
registrySecrets: RegistrySecrets,
) {
// Check for invalid options
const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag'];
this.checkInvalidOptions(
remoteOnlyOptions,
options,
'is only valid when pushing to an application',
);
const deviceDeploy = await import('../utils/device/deploy');
try {
await deviceDeploy.deployToDevice({
source: options.source,
deviceHost: localDeviceAddress,
dockerfilePath,
registrySecrets,
multiDockerignore: options['multi-dockerignore'],
nocache: options.nocache,
pull: options.pull,
nogitignore: !options.gitignore,
noParentCheck: options['noparent-check'],
nolive: options.nolive,
detached: options.detached,
services: options.service,
system: options.system,
env: options.env || [],
convertEol: !options['noconvert-eol'],
});
} catch (e) {
const { BuildError } = await import('../utils/device/errors');
if (instanceOf(e, BuildError)) {
throw new ExpectedError(e.toString());
} else {
throw e;
}
}
}
protected async getBuildTarget(appOrDevice: string): Promise<BuildTarget> {
const { validateLocalHostnameOrIp } = await import('../utils/validation');
return validateLocalHostnameOrIp(appOrDevice)
? BuildTarget.Device
: BuildTarget.Cloud;
}
protected checkInvalidOptions(
invalidOptions: Array<keyof FlagsDef>,
options: FlagsDef,
errorMessage: string,
) {
invalidOptions.forEach((opt) => {
if (options[opt]) {
throw new ExpectedError(`The --${opt} flag ${errorMessage}`);
}
});
}
}

View File

@ -50,8 +50,8 @@ export default class ScanCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
verbose: flags.boolean({
char: 'v',
default: false,
char: 'v',
description: 'display full info',
}),
timeout: flags.integer({
@ -60,6 +60,7 @@ export default class ScanCmd extends Command {
}),
help: cf.help,
json: flags.boolean({
default: false,
char: 'j',
description: 'produce JSON output instead of tabular output',
}),

View File

@ -19,11 +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 {
parseAsInteger,
validateDotLocalUrl,
validateIPAddress,
} from '../utils/validation';
import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
import * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
@ -39,14 +35,14 @@ interface ArgsDef {
service?: string;
}
export default class NoteCmd extends Command {
export default class SshCmd extends Command {
public static description = stripIndent`
SSH into the host or application container of a device.
Start a shell on a local or remote device. If a service name is not provided,
a shell will be opened on the host OS.
If an application name is provided, an interactive menu will be presented
If an application is provided, an interactive menu will be presented
for the selection of an online device. A shell will then be opened for the
host OS or service container of the chosen device.
@ -81,7 +77,8 @@ export default class NoteCmd extends Command {
public static args = [
{
name: 'applicationOrDevice',
description: 'application name, device uuid, or address of local device',
description:
'application name/slug/id, device uuid, or address of local device',
required: true,
},
{
@ -102,16 +99,19 @@ export default class NoteCmd extends Command {
parse: (p) => parseAsInteger(p, 'port'),
}),
tty: flags.boolean({
default: false,
description:
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
'force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
char: 't',
}),
verbose: flags.boolean({
description: 'Increase verbosity',
default: false,
description: 'increase verbosity',
char: 'v',
}),
noproxy: flags.boolean({
description: 'Bypass global proxy configuration for the ssh connection',
default: false,
description: 'bypass global proxy configuration for the ssh connection',
}),
help: cf.help,
};
@ -120,14 +120,11 @@ export default class NoteCmd extends Command {
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
NoteCmd,
SshCmd,
);
// if we're doing a direct SSH connection locally...
if (
validateDotLocalUrl(params.applicationOrDevice) ||
validateIPAddress(params.applicationOrDevice)
) {
// Local connection
if (validateLocalHostnameOrIp(params.applicationOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
address: params.applicationOrDevice,
@ -138,26 +135,27 @@ export default class NoteCmd extends Command {
});
}
// Remote connection
const { getProxyConfig, which } = await import('../utils/helpers');
const { checkLoggedIn, getOnlineTargetUuid } = await import(
'../utils/patterns'
);
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const sdk = getBalenaSdk();
const proxyConfig = getProxyConfig();
const useProxy = !!proxyConfig && !options.noproxy;
// this will be a tunnelled SSH connection...
await checkLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, params.applicationOrDevice);
let version: string | undefined;
let id: number | undefined;
await Command.checkLoggedIn();
const deviceUuid = await getOnlineTargetDeviceUuid(
sdk,
params.applicationOrDevice,
);
const device = await sdk.models.device.get(uuid, {
const device = await sdk.models.device.get(deviceUuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
id = device.id;
version = device.supervisor_version;
const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined,
@ -204,20 +202,13 @@ export default class NoteCmd extends Command {
const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
if (username == null) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
`Opening an SSH connection to a remote device requires you to be logged in.`,
);
}
// At this point, we have a long uuid with a device
// At this point, we have a long uuid of a device
// that we know exists and is accessible
let containerId: string | undefined;
if (params.service != null) {
containerId = await this.getContainerId(
sdk,
uuid,
deviceUuid,
params.service,
{
port: options.port,
@ -225,20 +216,20 @@ export default class NoteCmd extends Command {
proxyUrl: proxyUrl || '',
username: username!,
},
version,
id,
supervisorVersion,
deviceId,
);
}
let accessCommand: string;
if (containerId != null) {
accessCommand = `enter ${uuid} ${containerId}`;
accessCommand = `enter ${deviceUuid} ${containerId}`;
} else {
accessCommand = `host ${uuid}`;
accessCommand = `host ${deviceUuid}`;
}
const command = this.generateVpnSshCommand({
uuid,
uuid: deviceUuid,
command: accessCommand,
verbose: options.verbose,
port: options.port,

View File

@ -20,6 +20,7 @@ import Command from '../command';
import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getCliUx, stripIndent } from '../utils/lazy';
import { applicationIdInfo } from '../utils/messages';
interface FlagsDef {
application?: string;
@ -45,12 +46,14 @@ export default class SupportCmd extends Command {
Both --device and --application flags accept multiple values, specified as
a comma-separated list (with no spaces).
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'balena support enable --device ab346f,cd457a --duration 3d',
'balena support enable --application app3 --duration 12h',
'balena support disable -a myApp',
'balena support disable -a myorg/myapp',
];
public static args = [
@ -68,10 +71,11 @@ export default class SupportCmd extends Command {
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',
}),
application: {
...cf.application,
description:
'comma-separated list (no spaces) of application names or org/name slugs',
},
duration: flags.string({
description:
'length of time to enable support for, in (h)ours or (d)ays, e.g. 12h, 2d',

View File

@ -17,11 +17,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { disambiguateReleaseParam } from '../../utils/normalization';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
application?: string;
@ -40,10 +38,13 @@ export default class TagRmCmd extends Command {
Remove a tag from an application, device or release.
Remove a tag from an application, device or release.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena tag rm myTagKey --application MyApp',
'$ balena tag rm myTagKey -a myorg/myapp',
'$ balena tag rm myTagKey --device 7cf02a6',
'$ balena tag rm myTagKey --release 1234',
'$ balena tag rm myTagKey --release b376b0e544e9429483b656490e5b9443b4349bd6',
@ -64,6 +65,10 @@ export default class TagRmCmd extends Command {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['app', 'application', 'release'],
@ -73,10 +78,6 @@ export default class TagRmCmd extends Command {
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device', 'release'],
}),
};
public static authenticated = true;
@ -94,12 +95,16 @@ export default class TagRmCmd extends Command {
// Check user has specified one of application/device/release
if (!options.application && !options.device && !options.release) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(TagRmCmd.missingResourceMessage);
}
const { tryAsInteger } = await import('../../utils/validation');
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../../utils/sdk');
return balena.models.application.tags.remove(
tryAsInteger(options.application),
await getTypedApplicationIdentifier(balena, options.application),
params.tagKey,
);
}
@ -110,6 +115,9 @@ export default class TagRmCmd extends Command {
);
}
if (options.release) {
const { disambiguateReleaseParam } = await import(
'../../utils/normalization'
);
const releaseParam = await disambiguateReleaseParam(
balena,
options.release,
@ -122,7 +130,7 @@ export default class TagRmCmd extends Command {
protected static missingResourceMessage = stripIndent`
To remove a resource tag, you must provide exactly one of:
* An application, with --application <appname>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>

View File

@ -17,11 +17,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { disambiguateReleaseParam } from '../../utils/normalization';
import { tryAsInteger } from '../../utils/validation';
import { applicationIdInfo } from '../../utils/messages';
interface FlagsDef {
application?: string;
@ -45,10 +43,13 @@ export default class TagSetCmd extends Command {
You can optionally provide a value to be associated with the created
tag, as an extra argument after the tag key. If a value isn't
provided, a tag with an empty value is created.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena tag set mySimpleTag --application MyApp',
'$ balena tag set mySimpleTag -a myorg/myapp',
'$ balena tag set myCompositeTag myTagValue --application MyApp',
'$ balena tag set myCompositeTag myTagValue --device 7cf02a6',
'$ balena tag set myCompositeTag "my tag value with whitespaces" --device 7cf02a6',
@ -77,6 +78,10 @@ export default class TagSetCmd extends Command {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['app', 'application', 'release'],
@ -86,10 +91,6 @@ export default class TagSetCmd extends Command {
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device', 'release'],
}),
};
public static authenticated = true;
@ -107,14 +108,18 @@ export default class TagSetCmd extends Command {
// Check user has specified one of application/device/release
if (!options.application && !options.device && !options.release) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(TagSetCmd.missingResourceMessage);
}
params.value ??= '';
const { tryAsInteger } = await import('../../utils/validation');
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../../utils/sdk');
return balena.models.application.tags.set(
tryAsInteger(options.application),
await getTypedApplicationIdentifier(balena, options.application),
params.tagKey,
params.value,
);
@ -127,6 +132,9 @@ export default class TagSetCmd extends Command {
);
}
if (options.release) {
const { disambiguateReleaseParam } = await import(
'../../utils/normalization'
);
const releaseParam = await disambiguateReleaseParam(
balena,
options.release,
@ -143,7 +151,7 @@ export default class TagSetCmd extends Command {
protected static missingResourceMessage = stripIndent`
To set a resource tag, you must provide exactly one of:
* An application, with --application <appname>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>

View File

@ -20,9 +20,7 @@ import Command from '../command';
import { ExpectedError } from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { disambiguateReleaseParam } from '../utils/normalization';
import { tryAsInteger } from '../utils/validation';
import { isV12 } from '../utils/version';
import { applicationIdInfo } from '../utils/messages';
interface FlagsDef {
application?: string;
@ -38,10 +36,13 @@ export default class TagsCmd extends Command {
List all tags and their values for a particular application,
device or release.
${applicationIdInfo.split('\n').join('\n\t\t')}
`;
public static examples = [
'$ balena tags --application MyApp',
'$ balena tags -a myorg/myapp',
'$ balena tags --device 7cf02a6',
'$ balena tags --release 1234',
'$ balena tags --release b376b0e544e9429483b656490e5b9443b4349bd6',
@ -54,6 +55,10 @@ export default class TagsCmd extends Command {
...cf.application,
exclusive: ['app', 'device', 'release'],
},
app: {
...cf.app,
exclusive: ['application', 'device', 'release'],
},
device: {
...cf.device,
exclusive: ['app', 'application', 'release'],
@ -63,10 +68,6 @@ export default class TagsCmd extends Command {
exclusive: ['app', 'application', 'device'],
},
help: cf.help,
app: flags.string({
description: "same as '--application'",
exclusive: ['application', 'device', 'release'],
}),
};
public static authenticated = true;
@ -85,11 +86,14 @@ export default class TagsCmd extends Command {
throw new ExpectedError(this.missingResourceMessage);
}
const { tryAsInteger } = await import('../utils/validation');
let tags;
if (options.application) {
const { getTypedApplicationIdentifier } = await import('../utils/sdk');
tags = await balena.models.application.tags.getAllByApplication(
tryAsInteger(options.application),
await getTypedApplicationIdentifier(balena, options.application),
);
}
if (options.device) {
@ -98,6 +102,9 @@ export default class TagsCmd extends Command {
);
}
if (options.release) {
const { disambiguateReleaseParam } = await import(
'../utils/normalization'
);
const releaseParam = await disambiguateReleaseParam(
balena,
options.release,
@ -110,17 +117,13 @@ export default class TagsCmd extends Command {
throw new ExpectedError('No tags found');
}
console.log(
isV12()
? getVisuals().table.horizontal(tags, ['tag_key', 'value'])
: getVisuals().table.horizontal(tags, ['id', 'tag_key', 'value']),
);
console.log(getVisuals().table.horizontal(tags, ['tag_key', 'value']));
}
protected missingResourceMessage = stripIndent`
To list tags for a resource, you must provide exactly one of:
* An application, with --application <appname>
* An application, with --application <appNameOrSlug>
* A device, with --device <uuid>
* A release, with --release <id or commit>

View File

@ -24,11 +24,7 @@ import {
} from '../errors';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { getOnlineTargetUuid } from '../utils/patterns';
import * as _ from 'lodash';
import { tunnelConnectionToDevice } from '../utils/tunnel';
import { createServer, Server, Socket } from 'net';
import { IArg } from '@oclif/parser/lib/args';
import type { Server, Socket } from 'net';
interface FlagsDef {
port: string[];
@ -43,17 +39,25 @@ export default class TunnelCmd extends Command {
public static description = stripIndent`
Tunnel local ports to your balenaOS device.
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
Use this command to open local TCP ports that tunnel to listening sockets in a
balenaOS device.
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
device running a web server listening on port 3000.
For example, this command could be used to expose the ssh server of a balenaOS
device (port number 22222) on the local machine, or to expose a web server
running on the device. The port numbers do not have be the same between the
device and the local machine, and multiple ports may be tunneled in a single
command line.
Port mappings are specified in the format: <remotePort>[:[localIP:]localPort]
localIP defaults to 'localhost', and localPort defaults to the specified remotePort value.
localIP defaults to 'localhost', and localPort defaults to the specified
remotePort value.
You can tunnel multiple ports at any given time.
Note: the -p (--port) flag must be provided at the end of the command line,
as per examples.
Note: Port mappings must come after the deviceOrApplication parameter, as per examples.
In the case of openBalena, the tunnel command in CLI v12.38.5 or later requires
openBalena v3.1.2 or later. Older CLI versions work with older openBalena
versions.
`;
public static examples = [
@ -73,10 +77,10 @@ export default class TunnelCmd extends Command {
'$ balena tunnel myApp -p 8080:3000 -p 8081:9000',
];
public static args: Array<IArg<any>> = [
public static args = [
{
name: 'deviceOrApplication',
description: 'device uuid or application name/id',
description: 'device uuid or application name/slug/id',
required: true,
},
];
@ -101,8 +105,7 @@ export default class TunnelCmd extends Command {
TunnelCmd,
);
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const logger = await Command.getLogger();
const sdk = getBalenaSdk();
const logConnection = (
@ -127,23 +130,30 @@ export default class TunnelCmd extends Command {
throw new NoPortsDefinedError();
}
const uuid = await getOnlineTargetUuid(sdk, params.deviceOrApplication);
// Ascertain device uuid
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const uuid = await getOnlineTargetDeviceUuid(
sdk,
params.deviceOrApplication,
);
const device = await sdk.models.device.get(uuid);
logger.logInfo(`Opening a tunnel to ${device.uuid}...`);
const _ = await import('lodash');
const localListeners = _.chain(options.port)
.map((mapping) => {
return this.parsePortMapping(mapping);
})
.map(async ({ localPort, localAddress, remotePort }) => {
try {
const { tunnelConnectionToDevice } = await import('../utils/tunnel');
const handler = await tunnelConnectionToDevice(
device.uuid,
remotePort,
sdk,
);
const { createServer } = await import('net');
const server = createServer(async (client: Socket) => {
try {
await handler(client);

View File

@ -20,8 +20,8 @@ import Command from '../command';
import { stripIndent } from '../utils/lazy';
interface FlagsDef {
all?: boolean;
json?: boolean;
all: boolean;
json: boolean;
help: void;
}
@ -59,14 +59,14 @@ export default class VersionCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
all: flags.boolean({
char: 'a',
default: false,
char: 'a',
description:
'include version information for additional components (Node.js)',
}),
json: flags.boolean({
char: 'j',
default: false,
char: 'j',
description:
'output version information in JSON format for programmatic use',
}),

View File

@ -20,8 +20,12 @@ import * as os from 'os';
import { TypedError } from 'typed-error';
import { getChalk, stripIndent } from './utils/lazy';
import { getHelp } from './utils/messages';
import { CliSettings } from './utils/bootstrap';
export class ExpectedError extends TypedError {}
export class ExpectedError extends TypedError {
public code?: string;
public exitCode?: number;
}
export class NotLoggedInError extends ExpectedError {}
@ -39,6 +43,8 @@ export class NoPortsDefinedError extends ExpectedError {
}
}
export class SIGINTError extends ExpectedError {}
/**
* instanceOf is a more reliable implementation of the plain `instanceof`
* typescript operator, for use with TypedError errors when the error
@ -99,6 +105,17 @@ function interpret(error: Error): string {
return error.message;
}
function loadDataDirectory(): string {
try {
const settings = new CliSettings();
return settings.get('dataDirectory') as string;
} catch {
return os.platform() === 'win32'
? 'C:\\Users\\<user>\\_balena'
: '$HOME/.balena';
}
}
const messages: {
[key: string]: (error: Error & { path?: string }) => string;
} = {
@ -122,6 +139,23 @@ const messages: {
EACCES: (e) => messages.EPERM(e),
BalenaSettingsPermissionError: () => {
const dataDirectory = loadDataDirectory();
return stripIndent`
Error reading data directory: "${dataDirectory}"
This error usually indicates that the user doesn't have permissions over that directory,
which can happen if balena CLI was executed as the root user.
${
os.platform() === 'win32'
? `Try resetting the ownership by opening a new Command Prompt as administrator and running: \`takeown /f ${dataDirectory} /r\``
: `Try resetting the ownership by running: \`sudo chown -R $(whoami) ${dataDirectory}\``
}
`;
},
ETIMEDOUT: () =>
'Oops something went wrong, please check your connection and try again.',
@ -142,12 +176,21 @@ const messages: {
Try logging in again with the "balena login" command.`,
};
// TODO remove these regexes when we have a way of uniquely indentifying errors.
// related issue https://github.com/balena-io/balena-sdk/issues/1025
// related issue https://github.com/balena-io/balena-cli/issues/2126
const EXPECTED_ERROR_REGEXES = [
/cannot also be provided when using/, // Exclusive flag errors are all expected
/^BalenaSettingsPermissionError/, // balena-settings-storage
/^BalenaAmbiguousApplication/, // balena-sdk
/^BalenaAmbiguousDevice/, // balena-sdk
/^BalenaApplicationNotFound/, // balena-sdk
/^BalenaDeviceNotFound/, // balena-sdk
/^BalenaExpiredToken/, // balena-sdk
/^BalenaInvalidDeviceType/, // balena-sdk
/Cannot deactivate devices/i, // balena-api
/Devices must be offline in order to be deactivated\.$/i, // balena-api
/^BalenaOrganizationNotFound/, // balena-sdk
/Request error: Unauthorized$/, // balena-sdk
/^Missing \d+ required arg/, // oclif parser: RequiredArgsError
/Missing required flag/, // oclif parser: RequiredFlagError
@ -171,12 +214,17 @@ async function sentryCaptureException(error: Error) {
await Sentry.close(1000);
} catch (e) {
if (process.env.DEBUG) {
console.error('Timeout reporting error to sentry.io');
console.error('[debug] Timeout reporting error to sentry.io');
}
}
}
export async function handleError(error: Error) {
export async function handleError(error: Error | string) {
// If a module has thrown a string, convert to error
if (typeof error === 'string') {
error = new Error(error);
}
// Set appropriate exitCode
process.exitCode =
(error as BalenaError).exitCode === 0
@ -206,8 +254,9 @@ export async function handleError(error: Error) {
if (!process.env.BALENARC_NO_SENTRY) {
await sentryCaptureException(error);
}
// Unhandled/unexpected error: ensure that the process terminates.
}
if (error instanceof SIGINTError || !isExpectedError) {
// SIGINT or unexpected error: ensure that the process terminates.
// The exit error code was set above through `process.exitCode`.
process.exit();
}
@ -241,6 +290,7 @@ export const printExpectedErrorMessage = function (message: string) {
* them and call this function.
*
* DEPRECATED: Use `throw new ExpectedError(<message>)` instead.
* If a specific process exit code x must be set, use process.exitCode = x
*/
export function exitWithExpectedError(message: string | Error): never {
if (message instanceof Error) {

24
lib/utils/common-args.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* @license
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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 { lowercaseIfSlug } from './normalization';
export const applicationRequired = {
name: 'application',
description: 'application name, slug (preferred), or numeric ID (deprecated)',
required: true,
parse: lowercaseIfSlug,
};

View File

@ -19,14 +19,17 @@ import { flags } from '@oclif/command';
import type { IBooleanFlag } from '@oclif/parser/lib/flags';
import { stripIndent } from './lazy';
import { lowercaseIfSlug } from './normalization';
export const application = flags.string({
char: 'a',
description: 'application name',
description: 'application name, slug (preferred), or numeric ID (deprecated)',
parse: lowercaseIfSlug,
});
// TODO: Consider remove second alias 'app' when we can, to simplify.
export const app = flags.string({
description: "same as '--application'",
parse: lowercaseIfSlug,
});
export const device = flags.string({
@ -55,16 +58,19 @@ export const service = flags.string({
export const verbose: IBooleanFlag<boolean> = flags.boolean({
char: 'v',
description: 'produce verbose output',
default: false,
});
export const yes: IBooleanFlag<boolean> = flags.boolean({
char: 'y',
description: 'answer "yes" to all questions (non interactive use)',
default: false,
});
export const force: IBooleanFlag<boolean> = flags.boolean({
char: 'f',
description: 'force action if the update lock is set',
default: false,
});
export const drive = flags.string({
@ -75,3 +81,9 @@ export const drive = flags.string({
Check \`balena util available-drives\` for available options.
`,
});
export const json: IBooleanFlag<boolean> = flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
default: false,
});

View File

@ -55,6 +55,7 @@ export interface ComposeOpts {
noParentCheck: boolean;
projectName: string;
projectPath: string;
isLocal?: boolean;
}
export interface ComposeCliFlags {
@ -81,7 +82,16 @@ export interface ComposeProject {
export interface Release {
client: ReturnType<typeof import('balena-release').createClient>;
release: Partial<import('balena-release/build/models').ReleaseModel>;
release: Pick<
import('balena-release/build/models').ReleaseModel,
| 'id'
| 'status'
| 'commit'
| 'composition'
| 'source'
| 'start_timestamp'
| 'end_timestamp'
>;
serviceImages: Partial<import('balena-release/build/models').ImageModel>;
}

View File

@ -200,11 +200,14 @@ export const createRelease = async function (
return {
client,
release: _.omit(release, [
'created_at',
'belongs_to__application',
'is_created_by__user',
'__metadata',
release: _.pick(release, [
'id',
'status',
'commit',
'composition',
'source',
'start_timestamp',
'end_timestamp',
]),
serviceImages: _.mapValues(serviceImages, (serviceImage) =>
_.omit(serviceImage, [
@ -366,15 +369,15 @@ export const pushAndUpdateServiceImages = function (
images.map(({ serviceImage, localImage, props, logs }, index) =>
Promise.all([
localImage.inspect().then((img) => img.Size),
retry(
retry({
// @ts-ignore
() => progress.push(localImage.name, reporters[index], opts),
3, // `times` - retry 3 times
func: () => progress.push(localImage.name, reporters[index], opts),
maxAttempts: 3, // try calling func 3 times (max)
// @ts-ignore
localImage.name, // `label` included in retry log messages
2000, // `delayMs` - wait 2 seconds before the 1st retry
1.4, // `backoffScaler` - wait multiplier for each retry
).finally(renderer.end),
label: localImage.name, // label for retry log messages
initialDelayMs: 2000, // wait 2 seconds before the 1st retry
backoffScaler: 1.4, // wait multiplier for each retry
}).finally(renderer.end),
])
.then(
/** @type {([number, string]) => void} */

View File

@ -44,6 +44,60 @@ import type { DeviceInfo } from './device/api';
import { getBalenaSdk, getChalk, stripIndent } from './lazy';
import Logger = require('./logger');
/**
* Given an array representing the raw `--release-tag` flag of the deploy and
* push commands, parse it into separate arrays of release tag keys and values.
* The returned keys and values arrays are guaranteed to be of the same length.
*/
export function parseReleaseTagKeysAndValues(
releaseTags: string[],
): { releaseTagKeys: string[]; releaseTagValues: string[] } {
if (releaseTags.length === 0) {
return { releaseTagKeys: [], releaseTagValues: [] };
}
const releaseTagKeys = releaseTags.filter((_v, i) => i % 2 === 0);
const releaseTagValues = releaseTags.filter((_v, i) => i % 2 === 1);
releaseTagKeys.forEach((key: string) => {
if (key === '') {
throw new ExpectedError(`Error: --release-tag keys cannot be empty`);
}
if (/\s/.test(key)) {
throw new ExpectedError(
`Error: --release-tag keys cannot contain whitespaces`,
);
}
});
if (releaseTagKeys.length !== releaseTagValues.length) {
releaseTagValues.push('');
}
return { releaseTagKeys, releaseTagValues };
}
/**
* Use the balena SDK `models.release.tags.set()` method to set release tags
* for the given release ID. The releaseTagKeys and releaseTagValues arrays
* must be of the same length; their items map 1-to-1 to form key-value pairs.
*/
export async function applyReleaseTagKeysAndValues(
sdk: BalenaSDK,
releaseId: number,
releaseTagKeys: string[],
releaseTagValues: string[],
) {
if (releaseTagKeys.length === 0) {
return;
}
await Promise.all(
(_.zip(releaseTagKeys, releaseTagValues) as Array<[string, string]>).map(
async ([key, value]) => {
await sdk.models.release.tags.set(releaseId, key, value);
},
),
);
}
const exists = async (filename: string) => {
try {
await fs.access(filename);
@ -82,6 +136,7 @@ export async function loadProject(
} else {
logger.logDebug('Resolving project...');
[composeName, composeStr] = await resolveProject(logger, opts.projectPath);
if (composeName) {
if (opts.dockerfilePath) {
logger.logWarn(
@ -94,11 +149,52 @@ export async function loadProject(
);
composeStr = compose.defaultComposition(undefined, opts.dockerfilePath);
}
// If local push, merge dev compose overlay
if (opts.isLocal) {
composeStr = await mergeDevComposeOverlay(
logger,
composeStr,
opts.projectPath,
);
}
}
logger.logDebug('Creating project...');
return createProject(opts.projectPath, composeStr, opts.projectName);
}
/**
* Check for existence of docker-compose dev overlay file
* and merge in services definitions.
*/
async function mergeDevComposeOverlay(
logger: Logger,
composeStr: string,
projectRoot: string,
) {
const devOverlayFilename = 'docker-compose.dev.yml';
const devOverlayPath = path.join(projectRoot, devOverlayFilename);
if (await exists(devOverlayPath)) {
logger.logInfo(
`Docker compose dev overlay detected (${devOverlayFilename}) - merging.`,
);
try {
const yaml = await import('js-yaml');
const compose = yaml.load(composeStr);
const devOverlay = yaml.load(await fs.readFile(devOverlayPath, 'utf8'));
// We only want to merge the services section
compose.services = { ...compose.services, ...devOverlay.services };
composeStr = yaml.dump(compose);
} catch (err) {
err.message = `Error merging docker compose dev overlay file "${devOverlayPath}":\n${err.message}`;
throw err;
}
}
return composeStr;
}
/**
* Look into the given directory for valid compose files and return
* the contents of the first one found.
@ -127,6 +223,7 @@ async function resolveProject(
if (!quiet && !composeFileName) {
logger.logInfo(`No "docker-compose.yml" file found at "${projectRoot}"`);
}
return [composeFileName, composeFileContents];
}
@ -1128,15 +1225,10 @@ export async function validateProjectDirectory(
checkCompose(path.join(opts.projectPath, '..')),
]);
if (!hasCompose && hasParentCompose) {
const { isV12 } = await import('./version');
const msg = stripIndent`
"docker-compose.y[a]ml" file found in parent directory: please check that
the correct source folder was specified. (Suppress with '--noparent-check'.)`;
if (isV12()) {
throw new ExpectedError(`Error: ${msg}`);
} else {
Logger.getLogger().logWarn(msg);
}
throw new ExpectedError(`Error: ${msg}`);
}
}
}
@ -1204,7 +1296,7 @@ export async function deployProject(
auth: string,
apiEndpoint: string,
skipLogUpload: boolean,
): Promise<Partial<import('balena-release/build/models').ReleaseModel>> {
): Promise<import('balena-release/build/models').ReleaseModel> {
const releaseMod = await import('balena-release');
const { createRelease, tagServiceImages } = await import('./compose');
const tty = (await import('./tty'))(process.stdout);

View File

@ -28,6 +28,7 @@ import {
import type { Readable } from 'stream';
import { BALENA_ENGINE_TMP_PATH } from '../../config';
import { ExpectedError } from '../../errors';
import {
checkBuildSecretsRequirements,
loadProject,
@ -37,9 +38,9 @@ import {
import Logger = require('../logger');
import { DeviceAPI, DeviceInfo } from './api';
import * as LocalPushErrors from './errors';
import { DeviceAPIError } from './errors';
import LivepushManager from './live';
import { displayBuildLog } from './logs';
import { stripIndent } from '../lazy';
const LOCAL_APPNAME = 'localapp';
const LOCAL_RELEASEHASH = 'localrelease';
@ -76,7 +77,6 @@ async function environmentFromInput(
serviceNames: string[],
logger: Logger,
): Promise<ParsedEnvironment> {
const { exitWithExpectedError } = await import('../../errors');
// A normal environment variable regex, with an added part
// to find a colon followed servicename at the start
const varRegex = /^(?:([^\s:]+):)?([^\s]+?)=(.*)$/;
@ -92,7 +92,7 @@ async function environmentFromInput(
for (const env of envs) {
const maybeMatch = env.match(varRegex);
if (maybeMatch == null) {
exitWithExpectedError(`Unable to parse environment variable: ${env}`);
throw new ExpectedError(`Unable to parse environment variable: ${env}`);
}
const match = maybeMatch!;
let service: string | undefined;
@ -122,9 +122,6 @@ async function environmentFromInput(
}
export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
const { exitWithExpectedError } = await import('../../errors');
const { displayDeviceLogs } = await import('./logs');
// Resolve .local addresses to IP to avoid
// issue with Windows and rapid repeat lookups.
// see: https://github.com/balena-io/balena-cli/issues/1518
@ -137,16 +134,19 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
opts.deviceHost = address;
}
const api = new DeviceAPI(globalLogger, opts.deviceHost);
const port = 48484;
const api = new DeviceAPI(globalLogger, opts.deviceHost, port);
// First check that we can access the device with a ping
try {
globalLogger.logDebug('Checking we can access device');
await api.ping();
} catch (e) {
exitWithExpectedError(
`Could not communicate with local mode device at address ${opts.deviceHost}`,
);
throw new ExpectedError(stripIndent`
Could not communicate with device supervisor at address ${opts.deviceHost}:${port}.
Device may not have local mode enabled. Check with:
balena device local-mode <device-uuid>
`);
}
const versionError = new Error(
@ -156,9 +156,9 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
try {
const version = await api.getVersion();
globalLogger.logDebug(`Checking device version: ${version}`);
globalLogger.logDebug(`Checking device supervisor version: ${version}`);
if (!semver.satisfies(version, '>=7.21.4')) {
exitWithExpectedError(versionError);
throw new ExpectedError(versionError);
}
if (!opts.nolive && !semver.satisfies(version, '>=9.7.0')) {
globalLogger.logWarn(
@ -169,8 +169,8 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
} catch (e) {
// Very old supervisor versions do not support /version endpoint
// a DeviceAPIError is expected in this case
if (e instanceof DeviceAPIError) {
exitWithExpectedError(versionError);
if (e instanceof LocalPushErrors.DeviceAPIError) {
throw new ExpectedError(versionError);
} else {
throw e;
}
@ -186,6 +186,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
noParentCheck: opts.noParentCheck,
projectName: 'local',
projectPath: opts.source,
isLocal: true,
});
// Attempt to attach to the device's docker daemon
@ -210,7 +211,10 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
if (!opts.nolive) {
buildLogs = {};
}
const buildTasks = await performBuilds(
const { awaitInterruptibleTask } = await import('../helpers');
const buildTasks = await awaitInterruptibleTask<typeof performBuilds>(
performBuilds,
project.composition,
tarStream,
docker,
@ -220,6 +224,10 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
buildLogs,
);
globalLogger.outputDeferredMessages();
// Print a newline to clearly separate build time and runtime
console.log();
const envs = await environmentFromInput(
opts.env,
Object.getOwnPropertyNames(project.composition.services),
@ -244,10 +252,11 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
// Now that we've set the target state, the device will do it's thing
// so we can either just display the logs, or start a livepush session
// (whilst also display logs)
const promises: Array<Promise<void>> = [streamDeviceLogs(api, opts)];
let livepush: LivepushManager | null = null;
if (!opts.nolive) {
// Print a newline to clear seperate build time and runtime
console.log();
const livepush = new LivepushManager({
livepush = new LivepushManager({
api,
buildContext: opts.source,
buildTasks,
@ -257,41 +266,40 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
buildLogs: buildLogs!,
deployOpts: opts,
});
const promises: Array<Promise<void>> = [livepush.init()];
// Only show logs if we're not detaching
if (!opts.detached) {
const logStream = await api.getLogStream();
globalLogger.logInfo('Streaming device logs...');
promises.push(
displayDeviceLogs(logStream, globalLogger, opts.system, opts.services),
);
} else {
promises.push(livepush.init());
if (opts.detached) {
globalLogger.logLivepush(
'Running in detached mode, no service logs will be shown',
);
}
globalLogger.logLivepush('Watching for file changes...');
globalLogger.outputDeferredMessages();
await Promise.all(promises);
} else {
if (opts.detached) {
return;
}
// Print an empty newline to separate the build output
// from the device output
console.log();
// Now all we need to do is stream back the logs
const logStream = await api.getLogStream();
globalLogger.logInfo('Streaming device logs...');
globalLogger.outputDeferredMessages();
await displayDeviceLogs(
logStream,
globalLogger,
opts.system,
opts.services,
);
}
try {
await awaitInterruptibleTask(() => Promise.all(promises));
} finally {
// Stop watching files after log streaming ends (e.g. on SIGINT)
livepush?.close();
await livepush?.cleanup();
}
}
async function streamDeviceLogs(
deviceApi: DeviceAPI,
opts: DeviceDeployOptions,
) {
// Only show logs if we're not detaching
if (opts.detached) {
return;
}
globalLogger.logInfo('Streaming device logs...');
const { connectAndDisplayDeviceLogs } = await import('./logs');
return connectAndDisplayDeviceLogs({
deviceApi,
logger: globalLogger,
system: opts.system || false,
filterServices: opts.services,
maxAttempts: 1001,
});
}
function connectToDocker(host: string, port: number): Docker {
@ -441,7 +449,9 @@ export async function rebuildSingleTask(
);
if (task == null) {
throw new Error(`Could not find build task for service ${serviceName}`);
throw new ExpectedError(
`Could not find build task for service ${serviceName}`,
);
}
await assignDockerBuildOpts(docker, [task], opts);
@ -610,8 +620,6 @@ export function generateTargetState(
}
async function inspectBuildResults(images: LocalImage[]): Promise<void> {
const { exitWithExpectedError } = await import('../../errors');
const failures: LocalPushErrors.BuildFailure[] = [];
_.each(images, (image) => {
@ -624,6 +632,6 @@ async function inspectBuildResults(images: LocalImage[]): Promise<void> {
});
if (failures.length > 0) {
exitWithExpectedError(new LocalPushErrors.BuildError(failures).toString());
throw new LocalPushErrors.BuildError(failures).toString();
}
}

View File

@ -1,12 +1,29 @@
/**
* @license
* Copyright 2018-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 { TypedError } from 'typed-error';
import { ExpectedError } from '../../errors';
export interface BuildFailure {
error: Error;
serviceName: string;
}
export class BuildError extends TypedError {
export class BuildError extends ExpectedError {
private failures: BuildFailure[];
public constructor(failures: BuildFailure[]) {
@ -33,7 +50,7 @@ export class BuildError extends TypedError {
}
}
export class DeviceAPIError extends TypedError {}
export class DeviceAPIError extends ExpectedError {}
export class BadRequestDeviceAPIError extends DeviceAPIError {}
export class ServiceUnavailableAPIError extends DeviceAPIError {}

View File

@ -219,24 +219,17 @@ export class LivepushManager {
this.rebuildsCancelled[serviceName] = false;
}
}
}
// Setup cleanup handlers for the device
// This is necessary because the `exit-hook` module is used by several
// dependencies, and will exit without calling the following handler.
// Once https://github.com/balena-io/balena-cli/issues/867 has been solved,
// we are free to (and definitely should) remove the below line
process.removeAllListeners('SIGINT');
process.on('SIGINT', async () => {
this.logger.logLivepush('Cleaning up device...');
await Promise.all(
_.map(this.containers, (container) => {
container.livepush.cleanupIntermediateContainers();
}),
);
process.exit(0);
});
/** Delete intermediate build containers from the device */
public async cleanup() {
this.logger.logLivepush('Cleaning up device...');
await Promise.all(
_.map(this.containers, (container) =>
container.livepush.cleanupIntermediateContainers(),
),
);
this.logger.logDebug('Cleaning up done.');
}
protected setupFilesystemWatcher(
@ -290,6 +283,17 @@ export class LivepushManager {
return monitor;
}
/** Stop the filesystem watcher, allowing the Node process to exit gracefully */
public close() {
for (const container of Object.values(this.containers)) {
container.monitor.close().catch((err) => {
if (process.env.DEBUG) {
this.logger.logDebug(`chokidar.close() ${err.message}`);
}
});
}
}
public static preprocessDockerfile(content: string): string {
return new Dockerfile(content).generateLiveDockerfile();
}

View File

@ -1,9 +1,33 @@
/**
* @license
* Copyright 2018-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 ColorHash = require('color-hash');
import * as _ from 'lodash';
import type { Readable } from 'stream';
import { getChalk } from '../lazy';
import Logger = require('../logger');
import { ExpectedError, SIGINTError } from '../../errors';
class DeviceConnectionLostError extends ExpectedError {
public static defaultMsg = 'Connection to device lost';
constructor(msg?: string) {
super(msg || DeviceConnectionLostError.defaultMsg);
}
}
interface Log {
message: string;
@ -32,23 +56,81 @@ interface BuildLog {
* @param filterService Filter the logs so that only logs
* from a single service will be displayed
*/
export function displayDeviceLogs(
async function displayDeviceLogs(
logs: Readable,
logger: Logger,
system: boolean,
filterServices?: string[],
): Promise<void> {
return new Promise((resolve, reject) => {
logs.on('data', (log) => {
displayLogLine(log, logger, system, filterServices);
const { addSIGINTHandler } = await import('../helpers');
let gotSignal = false;
const handleSignal = () => {
gotSignal = true;
logs.emit('close');
};
addSIGINTHandler(handleSignal);
process.once('SIGTERM', handleSignal);
try {
await new Promise((_resolve, reject) => {
logs.on('data', (log) => {
displayLogLine(log, logger, system, filterServices);
});
logs.once('error', reject);
logs.once('end', () => {
logger.logWarn(DeviceConnectionLostError.defaultMsg);
if (gotSignal) {
reject(new SIGINTError('Log streaming aborted on SIGINT signal'));
} else {
reject(new DeviceConnectionLostError());
}
});
});
} finally {
process.removeListener('SIGINT', handleSignal);
process.removeListener('SIGTERM', handleSignal);
}
}
logs.on('error', reject);
logs.on('end', () => {
logger.logError('Connection to device lost');
resolve();
/**
* Open a TCP connection to the device's supervisor (TCP port 48484) and tail
* (display) device logs. Retry (reconnect) up to maxAttempts times if the
* TCP connection drops. Don't retry on SIGINT (CTRL-C).
* See function `displayDeviceLogs` for parameter documentation.
*/
export async function connectAndDisplayDeviceLogs({
deviceApi,
logger,
system,
filterServices,
maxAttempts = 3,
}: {
deviceApi: import('./api').DeviceAPI;
logger: Logger;
system: boolean;
filterServices?: string[];
maxAttempts?: number;
}) {
async function connectAndDisplay() {
// Open a new connection to the device's supervisor, TCP port 48484
const logStream = await deviceApi.getLogStream();
return displayDeviceLogs(logStream, logger, system, filterServices);
}
const { retry } = await import('../../utils/helpers');
try {
await retry({
func: connectAndDisplay,
maxAttempts,
label: 'Streaming logs',
});
});
} catch (err) {
if (err instanceof DeviceConnectionLostError) {
err.message = `Max retry count (${
maxAttempts - 1
}) exceeded while attempting to reconnect to the device`;
}
throw err;
}
}
export function displayBuildLog(log: BuildLog, logger: Logger): void {

View File

@ -17,7 +17,6 @@
import { promises as fs } from 'fs';
import Logger = require('./logger');
import { isV12 } from './version';
const globalLogger = Logger.getLogger();
@ -111,9 +110,7 @@ export async function readFileWithEolConversion(
);
// And summary warning later
globalLogger.deferredLog(
isV12()
? 'Windows-format line endings were detected in some files, but were not converted due to `--noconvert-eol` option.'
: 'Windows-format line endings were detected in some files. Consider using the `--convert-eol` option.',
'Windows-format line endings were detected in some files, but were not converted due to `--noconvert-eol` option.',
Logger.Level.WARN,
);

View File

@ -22,7 +22,7 @@ import * as os from 'os';
import type * as ShellEscape from 'shell-escape';
import type { Device, PineOptions } from 'balena-sdk';
import { ExpectedError } from '../errors';
import { ExpectedError, SIGINTError } from '../errors';
import { getBalenaSdk, getChalk, getVisuals } from './lazy';
import { promisify } from 'util';
import { isSubcommand } from '../preparser';
@ -204,39 +204,66 @@ function getApplication(
) as Promise<ApplicationWithDeviceType>;
}
const second = 1000; // 1000 milliseconds
const minute = 60 * second;
export const delay = promisify(setTimeout);
/**
* Call `func`, and if func() throws an error or returns a promise that
* eventually rejects, retry it `times` many times, each time printing a
* log message including the given `label` and the error that led to
* retrying. Wait delayMs before the first retry, multiplying the wait
* by backoffScaler for each further attempt.
* eventually rejects, retry it `times` many times, each time printing a log
* message including the given `label` and the error that led to retrying.
* Wait initialDelayMs before the first retry. Before each further retry,
* the delay is reduced by the time elapsed since the last retry, and
* increased by multiplying the result by backoffScaler.
* @param func: The function to call and, if needed, retry calling
* @param times: How many times to retry calling func()
* @param maxAttempts: How many times (max) to try calling func().
* func() will always be called at least once.
* @param label: Label to include in the retry log message
* @param startingDelayMs: How long to wait before the first retry
* @param initialDelayMs: How long to wait before the first retry
* @param backoffScaler: Multiplier to previous wait time
* @param count: Used "internally" for the recursive calls
* @param maxSingleDelayMs: Maximum interval between retries
*/
export async function retry<T>(
func: () => T,
times: number,
label: string,
startingDelayMs = 1000,
export async function retry<T>({
func,
maxAttempts,
label,
initialDelayMs = 1000,
backoffScaler = 2,
): Promise<T> {
for (let count = 0; count < times - 1; count++) {
maxSingleDelayMs = 1 * minute,
}: {
func: () => T;
maxAttempts: number;
label: string;
initialDelayMs?: number;
backoffScaler?: number;
maxSingleDelayMs?: number;
}): Promise<T> {
let delayMs = initialDelayMs;
for (let count = 0; count < maxAttempts - 1; count++) {
const lastAttemptMs = Date.now();
try {
return await func();
} catch (err) {
const delayMS = backoffScaler ** count * startingDelayMs;
// Don't retry on SIGINT (CTRL-C)
if (err instanceof SIGINTError) {
throw err;
}
if (count) {
// use Math.max to work around system time changes, e.g. DST
const elapsedMs = Math.max(0, Date.now() - lastAttemptMs);
// reduce delayMs by the time elapsed since the last attempt
delayMs = Math.max(initialDelayMs, delayMs - elapsedMs);
// increase delayMs by the backoffScaler factor
delayMs = Math.min(maxSingleDelayMs, delayMs * backoffScaler);
}
const sec = delayMs / 1000;
const secStr = sec < 10 ? sec.toFixed(1) : Math.round(sec).toString();
console.log(
`Retrying "${label}" after ${(delayMS / 1000).toFixed(2)}s (${
count + 1
} of ${times}) due to: ${err}`,
`Retrying "${label}" after ${secStr}s (${count + 1} of ${
maxAttempts - 1
}) due to: ${err}`,
);
await delay(delayMS);
await delay(delayMs);
}
}
return await func();
@ -490,3 +517,61 @@ export const expandForAppName: PineOptions<Device> = {
is_running__release: { $select: 'commit' },
},
};
/**
* Use the `readline` library on Windows to install SIGINT handlers.
* This appears to be necessary on MSYS / Git for Windows, and also useful
* with PowerShell to avoid the built-in "Terminate batch job? (Y/N)" prompt
* that appears to result in ungraceful / abrupt process termination.
*/
const installReadlineSigintEmitter = _.once(function emitSigint() {
if (process.platform === 'win32') {
const readline = require('readline') as typeof import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on('SIGINT', () => process.emit('SIGINT' as any));
}
});
/**
* Centralized cross-platform logic to install a SIGINT handler
* @param sigintHandler The handler function
* @param once Whether the handler should be called no more than once
*/
export function addSIGINTHandler(sigintHandler: () => void, once = true) {
installReadlineSigintEmitter();
if (once) {
process.once('SIGINT', sigintHandler);
} else {
process.on('SIGINT', sigintHandler);
}
}
/**
* Call the given task function (which returns a promise) with the given
* arguments, await the returned promise and resolve to the same result.
* While awaiting for that promise, also await for a SIGINT signal (if any),
* with a new SIGINT handler that is automatically removed on return.
* If a SIGINT signal is received while awaiting for the task function,
* immediately return a promise that rejects with SIGINTError.
* @param task An async function to be executed and awaited
* @param theArgs Arguments to be passed to the task function
*/
export async function awaitInterruptibleTask<
T extends (...args: any[]) => Promise<any>
>(task: T, ...theArgs: Parameters<T>): Promise<ReturnType<T>> {
let sigintHandler: () => void = () => undefined;
const sigintPromise = new Promise<T>((_resolve, reject) => {
sigintHandler = () => {
reject(new SIGINTError('Task aborted on SIGINT signal'));
};
addSIGINTHandler(sigintHandler);
});
try {
return await Promise.race([sigintPromise, task(...theArgs)]);
} finally {
process.removeListener('SIGINT', sigintHandler);
}
}

View File

@ -38,9 +38,9 @@ export const balenaAsciiArt = `\
|_.__/ \\__,_||_| \\____/|_| |_| \\__,_|
`;
export const registrySecretsHelp = `\
REGISTRY SECRETS
The --registry-secrets option specifies a JSON or YAML file containing private
export const registrySecretsHelp =
'REGISTRY SECRETS \n' +
`The --registry-secrets option specifies a JSON or YAML file containing private
Docker registry usernames and passwords to be used when pulling base images.
Sample registry-secrets YAML file:
\`\`\`
@ -61,9 +61,9 @@ If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead.`;
export const dockerignoreHelp = `\
DOCKERIGNORE AND GITIGNORE FILES
By default, the balena CLI will use a single ".dockerignore" file (if any) at
export const dockerignoreHelp =
'DOCKERIGNORE AND GITIGNORE FILES \n' +
`By default, the balena CLI will use a single ".dockerignore" file (if any) at
the project root (--source directory) in order to decide which source files to
exclude from the "build context" (tar stream) sent to balenaCloud, Docker
daemon or balenaEngine. In a microservices (multicontainer) application, the
@ -83,7 +83,7 @@ any) defined at the overall project root will be used to filter files and
subdirectories other than service subdirectories. It will not have any effect
on service subdirectories, whether or not a service subdirectory defines its
own .dockerignore file. Multiple .dockerignore files are not merged or added
together, and cannot override or extend other files. This behavior maximises
together, and cannot override or extend other files. This behavior maximizes
compatibility with the standard docker-compose tool, while still allowing a
root .dockerignore file (at the overall project root) to filter files and
folders that are outside service subdirectories.
@ -94,8 +94,8 @@ option if compatibility is required. This option is mutually exclusive with
--multi-dockerignore (-m) and will be removed in the CLI's next major version
release (v13).
Default .dockerignore patterns
When --gitignore (-g) is NOT used (i.e. when not in v11 compatibility mode), a
Default .dockerignore patterns \n` +
`When --gitignore (-g) is NOT used (i.e. when not in v11 compatibility mode), a
few default/hardcoded dockerignore patterns are "merged" (in memory) with the
patterns found in the applicable .dockerignore files, in the following order:
\`\`\`
@ -113,3 +113,24 @@ adding counter patterns to the applicable .dockerignore file(s), for example
\`!mysubmodule/.git\`. For documentation on pattern format, see:
- https://docs.docker.com/engine/reference/builder/#dockerignore-file
- https://www.npmjs.com/package/@balena/dockerignore`;
export const applicationIdInfo = `\
Applications may be specified by app name, slug, or numeric ID. App slugs
are the recommended option, as they are unique and unambiguous. Slugs
can be listed with the \`balena apps\` command. Note that slugs may change
if the application is renamed.
App names are not unique and may result in "Application is ambiguous" errors
at any time (even if it "used to work in the past"), for example if the name
clashes with a newly created public application, or with apps from other balena
accounts that you may have been invited to as a member. For this reason, app
names are especially discouraged in scripts (e.g. CI environments).
Numeric app IDs are deprecated because they consist of an implementation detail
of the balena backend. We intend to remove support for numeric IDs at some point
in the future.`;
export const jsonInfo = `\
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/).`;

View File

@ -16,19 +16,8 @@
*/
import type { BalenaSDK } from 'balena-sdk';
import _ = require('lodash');
import { ExpectedError } from '../errors';
export function normalizeUuidProp(
params: { [key: string]: any },
propName = 'uuid',
) {
if (typeof params[propName] === 'number') {
params[propName] =
params[propName + '_raw'] || _.toString(params[propName]);
}
}
/**
* Takes a string which may represent one of:
* - Integer release id
@ -85,3 +74,10 @@ export async function disambiguateReleaseParam(
// Must be a number only uuid/hash (or nonexistent release)
return (await balena.models.release.get(release, { $select: 'id' })).id;
}
/**
* Convert to lowercase if looks like slug
*/
export function lowercaseIfSlug(s: string) {
return s.includes('/') ? s.toLowerCase() : s;
}

View File

@ -25,6 +25,9 @@ import {
import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
import validation = require('./validation');
import { delay } from './helpers';
import { isV13 } from './version';
import type { Application, Device, Organization } from 'balena-sdk';
import { getApplication } from './sdk';
export function authenticate(options: {}): Promise<void> {
const balena = getBalenaSdk();
@ -147,7 +150,11 @@ export async function confirm(
) {
if (yesOption) {
if (yesMessage) {
console.log(yesMessage);
if (isV13()) {
console.error(yesMessage);
} else {
console.log(yesMessage);
}
}
return;
}
@ -159,7 +166,8 @@ export async function confirm(
});
if (!confirmed) {
const err = new Error('Aborted');
const err = new ExpectedError('Aborted');
// TODO remove this deprecated function (exitWithExpectedError)
if (exitIfDeclined) {
exitWithExpectedError(err);
}
@ -180,7 +188,6 @@ export function selectApplication(
}
const apps = (await balena.models.application.getAll({
$select: 'app_name',
$expand: {
is_for__device_type: {
$select: 'slug',
@ -197,61 +204,25 @@ export function selectApplication(
message: 'Select an application',
type: 'list',
choices: _.map(applications, (application) => ({
name: `${application.app_name} (${application.is_for__device_type[0].slug})`,
value: application.app_name,
name: `${application.app_name} (${application.slug}) [${application.is_for__device_type[0].slug}]`,
value: application,
})),
});
});
}
export function selectOrCreateApplication() {
const balena = getBalenaSdk();
return balena.models.application
.hasAny()
.then((hasAnyApplications) => {
if (!hasAnyApplications) {
// Just to make TS happy
return Promise.resolve(undefined);
}
return (balena.models.application.getAll({
$expand: {
is_for__device_type: {
$select: 'slug',
},
},
}) as Promise<ApplicationWithDeviceType[]>).then((applications) => {
const appOptions: Array<{ name: string; value: string | null }> = _.map(
applications,
(application) => ({
name: `${application.app_name} (${application.is_for__device_type[0].slug})`,
value: application.app_name,
}),
);
appOptions.unshift({
name: 'Create a new application',
value: null,
});
return getCliForm().ask({
message: 'Select an application',
type: 'list',
choices: appOptions,
});
});
})
.then((application) => {
if (application) {
return application;
}
return getCliForm().ask({
message: 'Choose a Name for your new application',
type: 'input',
validate: validation.validateApplicationName,
});
});
export async function selectOrganization(organizations?: Organization[]) {
// Use either provided orgs (if e.g. already loaded) or load from cloud
organizations =
organizations || (await getBalenaSdk().models.organization.getAll());
return getCliForm().ask({
message: 'Select an organization',
type: 'list',
choices: organizations.map((org) => ({
name: `${org.name} (${org.handle})`,
value: org.handle,
})),
});
}
export async function awaitDevice(uuid: string) {
@ -359,99 +330,94 @@ export function inferOrSelectDevice(preferredUuid: string) {
});
}
async function getApplicationByIdOrName(
sdk: BalenaSdk.BalenaSDK,
idOrName: string,
) {
if (validation.looksLikeInteger(idOrName)) {
try {
return await sdk.models.application.get(Number(idOrName));
} catch (error) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (!instanceOf(error, BalenaApplicationNotFound)) {
throw error;
}
}
}
return await sdk.models.application.get(idOrName);
}
export async function getOnlineTargetUuid(
/*
* Given applicationOrDevice, which may be
* - an application name
* - an application slug
* - an application id (integer)
* - a device uuid
* Either:
* - in case of device uuid, return uuid of device after verifying that it exists and is online.
* - in case of application, return uuid of device user selects from list of online devices.
*
* TODO: Modify this when app IDs dropped.
*/
export async function getOnlineTargetDeviceUuid(
sdk: BalenaSdk.BalenaSDK,
applicationOrDevice: string,
) {
// applicationOrDevice can be:
// * an application name
// * an application ID (integer)
// * a device uuid
const Logger = await import('../utils/logger');
const logger = Logger.getLogger();
const appTest = validation.validateApplicationName(applicationOrDevice);
const uuidTest = validation.validateUuid(applicationOrDevice);
const logger = (await import('../utils/logger')).getLogger();
if (!appTest && !uuidTest) {
throw new ExpectedError(
`Device or application not found: ${applicationOrDevice}`,
);
// If looks like UUID, probably device
if (validation.validateUuid(applicationOrDevice)) {
let device: Device;
try {
logger.logDebug(
`Trying to fetch device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
);
device = await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid', 'is_online'],
});
if (!device.is_online) {
throw new ExpectedError(
`Device with UUID ${applicationOrDevice} is offline`,
);
}
return device.uuid;
} catch (err) {
const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(err, BalenaDeviceNotFound)) {
logger.logDebug(`Device with UUID ${applicationOrDevice} not found`);
// Now try app
} else {
throw err;
}
}
}
// if we have a definite device UUID...
if (uuidTest && !appTest) {
logger.logDebug(
`Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
);
return (
await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})
).uuid;
}
// otherwise, it may be a device OR an application...
// Not a device UUID, try app
let app: Application;
try {
logger.logDebug(
`Fetching application by ID or name ${applicationOrDevice} (${typeof applicationOrDevice})`,
`Trying to fetch application by name/slug/ID: ${applicationOrDevice}`,
);
const app = await getApplicationByIdOrName(sdk, applicationOrDevice);
const devices = await sdk.models.device.getAllByApplication(app.id, {
$filter: { is_online: true },
});
if (_.isEmpty(devices)) {
throw new ExpectedError('No accessible devices are online');
}
return await getCliForm().ask({
message: 'Select a device',
type: 'list',
default: devices[0].uuid,
choices: _.map(devices, (device) => ({
name: `${device.device_name || 'Untitled'} (${device.uuid.slice(
0,
7,
)})`,
value: device.uuid,
})),
});
app = await getApplication(sdk, applicationOrDevice);
} catch (err) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (!instanceOf(err, BalenaApplicationNotFound)) {
if (instanceOf(err, BalenaApplicationNotFound)) {
throw new ExpectedError(
`Application or Device not found: ${applicationOrDevice}`,
);
} else {
throw err;
}
logger.logDebug(`Application not found`);
}
// it wasn't an application, maybe it's a device...
logger.logDebug(
`Fetching device by UUID ${applicationOrDevice} (${typeof applicationOrDevice})`,
);
return (
await sdk.models.device.get(applicationOrDevice, {
$select: ['uuid'],
$filter: { is_online: true },
})
).uuid;
// App found, load its devices
const devices = await sdk.models.device.getAllByApplication(app.id, {
$select: ['device_name', 'uuid'],
$filter: { is_online: true },
});
// Throw if no devices online
if (_.isEmpty(devices)) {
throw new ExpectedError(
`Application ${app.slug} found, but has no devices online.`,
);
}
// Ask user to select from online devices for application
return getCliForm().ask({
message: `Select a device on application ${app.slug}`,
type: 'list',
default: devices[0].uuid,
choices: _.map(devices, (device) => ({
name: `${device.device_name || 'Untitled'} (${device.uuid.slice(0, 7)})`,
value: device.uuid,
})),
});
}
export function selectFromList<T>(

View File

@ -31,12 +31,12 @@ export async function join(
appUpdatePollInterval?: number,
): Promise<void> {
logger.logDebug('Determining device...');
const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp);
await assertDeviceIsCompatible(deviceIp);
logger.logDebug(`Using device: ${deviceIp}`);
deviceHostnameOrIp = deviceHostnameOrIp || (await selectLocalDevice());
await assertDeviceIsCompatible(deviceHostnameOrIp);
logger.logDebug(`Using device: ${deviceHostnameOrIp}`);
logger.logDebug('Determining device type...');
const deviceType = await getDeviceType(deviceIp);
const deviceType = await getDeviceType(deviceHostnameOrIp);
logger.logDebug(`Device type: ${deviceType}`);
logger.logDebug('Determining application...');
@ -50,7 +50,7 @@ export async function join(
}
logger.logDebug('Determining device OS version...');
const deviceOsVersion = await getOsVersion(deviceIp);
const deviceOsVersion = await getOsVersion(deviceHostnameOrIp);
logger.logDebug(`Device OS version: ${deviceOsVersion}`);
logger.logDebug('Generating application config...');
@ -61,8 +61,7 @@ export async function join(
logger.logDebug(`Using config: ${JSON.stringify(config, null, 2)}`);
logger.logDebug('Configuring...');
await configure(deviceIp, config);
logger.logDebug('All done.');
await configure(deviceHostnameOrIp, config);
const platformUrl = await sdk.settings.get('balenaUrl');
logger.logSuccess(`Device successfully joined ${platformUrl}!`);
@ -70,17 +69,15 @@ export async function join(
export async function leave(
logger: Logger,
_sdk: BalenaSdk.BalenaSDK,
deviceHostnameOrIp?: string,
): Promise<void> {
logger.logDebug('Determining device...');
const deviceIp = await getOrSelectLocalDevice(deviceHostnameOrIp);
await assertDeviceIsCompatible(deviceIp);
logger.logDebug(`Using device: ${deviceIp}`);
deviceHostnameOrIp = deviceHostnameOrIp || (await selectLocalDevice());
await assertDeviceIsCompatible(deviceHostnameOrIp);
logger.logDebug(`Using device: ${deviceHostnameOrIp}`);
logger.logDebug('Deconfiguring...');
await deconfigure(deviceIp);
logger.logDebug('All done.');
await deconfigure(deviceHostnameOrIp);
logger.logSuccess('Device successfully left the platform.');
}
@ -155,39 +152,21 @@ async function getOsVersion(deviceIp: string): Promise<string> {
return match[1];
}
async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
if (deviceIp) {
return deviceIp;
}
const through = await import('through2');
let ip: string | null = null;
const stream = through(function (data, _enc, cb) {
const match = /^==> Selected device: (.*)$/m.exec(data.toString());
if (match) {
ip = match[1];
cb();
async function selectLocalDevice(): Promise<string> {
const { forms } = await import('balena-sync');
let hostnameOrIp;
try {
hostnameOrIp = await forms.selectLocalBalenaOsDevice();
console.error(`==> Selected device: ${hostnameOrIp}`);
} catch (e) {
if (e.message.toLowerCase().includes('could not find any')) {
throw new ExpectedError(e);
} else {
cb(null, data);
throw e;
}
});
stream.pipe(process.stderr);
const { sudo } = await import('../utils/helpers');
const command = ['internal', 'scandevices'];
await sudo(command, {
stderr: stream,
msg:
'Scanning for local devices. If asked, please type your computer password.',
});
if (!ip) {
throw new ExpectedError('No device selected');
}
return ip;
return hostnameOrIp;
}
async function selectAppFromList(

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2017-2020 Balena Ltd.
* Copyright 2017-2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,10 +16,12 @@
*/
import type * as Dockerode from 'dockerode';
import { ExpectedError } from '../errors';
import { getBalenaSdk, stripIndent } from './lazy';
import Logger = require('./logger');
export const QEMU_VERSION = 'v4.0.0+balena2';
export const QEMU_VERSION = 'v5.2.0+balena4';
export const QEMU_BIN_NAME = 'qemu-execve';
export function qemuPathInContext(context: string) {
@ -63,7 +65,8 @@ export function copyQemu(context: string, arch: string) {
.then(() => path.relative(context, binPath));
}
export const getQemuPath = function (arch: string) {
export const getQemuPath = function (balenaArch: string) {
const qemuArch = balenaArchToQemuArch(balenaArch);
const balena = getBalenaSdk();
const path = require('path') as typeof import('path');
const { promises: fs } = require('fs') as typeof import('fs');
@ -79,64 +82,76 @@ export const getQemuPath = function (arch: string) {
throw err;
})
.then(() =>
path.join(binDir, `${QEMU_BIN_NAME}-${arch}-${QEMU_VERSION}`),
path.join(binDir, `${QEMU_BIN_NAME}-${qemuArch}-${QEMU_VERSION}`),
),
);
};
export function installQemu(arch: string) {
const request = require('request') as typeof import('request');
const fs = require('fs') as typeof import('fs');
const zlib = require('zlib') as typeof import('zlib');
const tar = require('tar-stream') as typeof import('tar-stream');
async function installQemu(arch: string, qemuPath: string) {
const qemuArch = balenaArchToQemuArch(arch);
const fileVersion = QEMU_VERSION.replace('v', '').replace('+', '.');
const urlFile = encodeURIComponent(`qemu-${fileVersion}-${qemuArch}.tar.gz`);
const urlVersion = encodeURIComponent(QEMU_VERSION);
const qemuUrl = `https://github.com/balena-io/qemu/releases/download/${urlVersion}/${urlFile}`;
return getQemuPath(arch).then(
(qemuPath) =>
new Promise(function (resolve, reject) {
const installStream = fs.createWriteStream(qemuPath);
const request = await import('request');
const fs = await import('fs');
const zlib = await import('zlib');
const tar = await import('tar-stream');
const qemuArch = balenaArchToQemuArch(arch);
const fileVersion = QEMU_VERSION.replace(/^v/, '').replace('+', '.');
const urlFile = encodeURIComponent(
`qemu-${fileVersion}-${qemuArch}.tar.gz`,
);
const urlVersion = encodeURIComponent(QEMU_VERSION);
const qemuUrl = `https://github.com/balena-io/qemu/releases/download/${urlVersion}/${urlFile}`;
const extract = tar.extract();
extract.on('entry', function (header, stream, next) {
// createWriteStream creates a zero-length file on disk that
// needs to be deleted if the download fails
const installStream = fs.createWriteStream(qemuPath);
try {
await new Promise<void>((resolve, reject) => {
const extract = tar.extract();
extract.on('entry', function (header, stream, next) {
try {
stream.on('end', next);
if (header.name.includes(`qemu-${qemuArch}-static`)) {
stream.pipe(installStream);
} else {
stream.resume();
}
} catch (err) {
reject(err);
}
});
request(qemuUrl)
.on('error', reject)
.pipe(zlib.createGunzip())
.on('error', reject)
.pipe(extract)
.on('error', reject)
.on('finish', function () {
fs.chmodSync(qemuPath, '755');
resolve();
});
return request(qemuUrl)
.on('error', reject)
.pipe(zlib.createGunzip())
.on('error', reject)
.pipe(extract)
.on('error', reject)
.on('finish', function () {
fs.chmodSync(qemuPath, '755');
resolve();
});
}),
);
});
} catch (err) {
try {
await fs.promises.unlink(qemuPath);
} catch {
// ignore
}
throw err;
}
}
const balenaArchToQemuArch = function (arch: string) {
switch (arch) {
case 'armv7hf':
case 'rpi':
case 'arm':
case 'armhf':
case 'armv7hf':
return 'arm';
case 'arm64':
case 'aarch64':
return 'aarch64';
default:
throw new Error(`Cannot install emulator for architecture ${arch}`);
throw new ExpectedError(stripIndent`
Unknown ARM architecture identifier "${arch}".
Known ARM identifiers: rpi arm armhf armv7hf arm64 aarch64`);
}
};
@ -155,11 +170,17 @@ export async function installQemuIfNeeded(
const { promises: fs } = await import('fs');
const qemuPath = await getQemuPath(arch);
try {
const stats = await fs.stat(qemuPath);
// Earlier versions of the CLI with broken error handling would leave
// behind files with size 0. If such a file is found, delete it.
if (stats.size === 0) {
await fs.unlink(qemuPath);
}
await fs.access(qemuPath);
} catch {
// Qemu doesn't exist so install it
// QEMU not found in cache folder (~/.balena/bin/), so install it
logger.logInfo(`Installing qemu for ${arch} emulation...`);
await installQemu(arch);
await installQemu(arch, qemuPath);
}
return true;
}

View File

@ -22,8 +22,7 @@ import type * as Stream from 'stream';
import streamToPromise = require('stream-to-promise');
import type { Pack } from 'tar-stream';
import { ExpectedError } from '../errors';
import { exitWithExpectedError } from '../errors';
import { ExpectedError, SIGINTError } from '../errors';
import { tarDirectory } from './compose_ts';
import { getVisuals, stripIndent } from './lazy';
import Logger = require('./logger');
@ -46,16 +45,13 @@ export interface BuildOpts {
}
export interface RemoteBuild {
app: string;
owner: string;
appSlug: string;
source: string;
auth: string;
baseUrl: string;
nogitignore: boolean;
opts: BuildOpts;
sdk: BalenaSDK;
// For internal use
releaseId?: number;
hadError?: boolean;
@ -86,14 +82,12 @@ export class RemoteBuildFailedError extends ExpectedError {
async function getBuilderEndpoint(
baseUrl: string,
owner: string,
app: string,
appSlug: string,
opts: BuildOpts,
): Promise<string> {
const querystring = await import('querystring');
const args = querystring.stringify({
owner,
app,
slug: appSlug,
dockerfilePath: opts.dockerfilePath,
emulated: opts.emulated,
nocache: opts.nocache,
@ -109,72 +103,87 @@ async function getBuilderEndpoint(
return `${builderUrl}/v3/build?${args}`;
}
export async function startRemoteBuild(build: RemoteBuild): Promise<void> {
const stream = await getRemoteBuildStream(build);
export async function startRemoteBuild(
build: RemoteBuild,
): Promise<number | undefined> {
const [buildRequest, stream] = await getRemoteBuildStream(build);
// Special windows handling (win64 also reports win32)
if (process.platform === 'win32') {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Setup CTRL-C handler so the user can interrupt the build
let cancellationPromise = Promise.resolve();
const sigintHandler = () => {
process.exitCode = 130;
console.error('\nReceived SIGINT, cleaning up. Please wait.');
try {
cancellationPromise = cancelBuildIfNecessary(build);
} catch (err) {
console.error(err.message);
} finally {
buildRequest.abort();
const sigintErr = new SIGINTError('Build aborted on SIGINT signal');
sigintErr.code = 'SIGINT';
stream.emit('error', sigintErr);
}
};
rl.on('SIGINT', () => process.emit('SIGINT' as any));
const { addSIGINTHandler } = await import('./helpers');
addSIGINTHandler(sigintHandler);
try {
if (build.opts.headless) {
await handleHeadlessBuildStream(build, stream);
} else {
await handleRemoteBuildStream(build, stream);
}
} finally {
process.removeListener('SIGINT', sigintHandler);
globalLogger.outputDeferredMessages();
await cancellationPromise;
}
return build.releaseId;
}
if (!build.opts.headless) {
return new Promise((resolve, reject) => {
// Setup interrupt handlers so we can cancel the build if the user presses
// ctrl+c
// This is necessary because the `exit-hook` module is used by several
// dependencies, and will exit without calling the following handler.
// Once https://github.com/balena-io/balena-cli/issues/867 has been solved,
// we are free to (and definitely should) remove the below line
process.removeAllListeners('SIGINT');
process.on('SIGINT', () => {
process.stderr.write('Received SIGINT, cleaning up. Please wait.\n');
cancelBuildIfNecessary(build).then(() => {
stream.end();
process.exit(130);
});
});
stream.on('data', getBuilderMessageHandler(build));
stream.on('end', resolve);
stream.on('error', reject);
}).then(() => {
globalLogger.outputDeferredMessages();
if (build.hadError) {
throw new RemoteBuildFailedError();
}
});
async function handleRemoteBuildStream(
build: RemoteBuild,
stream: Stream.Stream,
) {
await new Promise((resolve, reject) => {
const msgHandler = getBuilderMessageHandler(build);
stream.on('data', msgHandler);
stream.once('end', resolve);
stream.once('error', reject);
});
if (build.hadError) {
throw new RemoteBuildFailedError();
}
}
async function handleHeadlessBuildStream(
build: RemoteBuild,
stream: Stream.Stream,
) {
// We're running a headless build, which means we'll
// get a single object back, detailing if the build has
// been started
let result: HeadlessBuilderMessage;
let message: HeadlessBuilderMessage;
try {
const response = await streamToPromise(stream);
result = JSON.parse(response.toString());
const response = await streamToPromise(stream as NodeJS.ReadWriteStream);
message = JSON.parse(response.toString());
} catch (e) {
if (e.code === 'SIGINT') {
throw e;
}
throw new Error(
`There was an error reading the response from the remote builder: ${e}`,
);
}
handleHeadlessBuildMessage(result);
}
function handleHeadlessBuildMessage(message: HeadlessBuilderMessage) {
if (!process.stdout.isTTY) {
process.stdout.write(JSON.stringify(message));
return;
}
if (message.started) {
console.log('Build successfully started');
console.log(` Release ID: ${message.releaseId!}`);
build.releaseId = message.releaseId;
} else {
console.log('Failed to start remote build');
console.log(` Error: ${message.error!}`);
@ -255,6 +264,9 @@ function getBuilderMessageHandler(
async function cancelBuildIfNecessary(build: RemoteBuild): Promise<void> {
if (build.releaseId != null) {
console.error(
`Setting 'cancelled' release status for release ID ${build.releaseId} ...`,
);
await build.sdk.pine.patch({
resource: 'release',
id: build.releaseId,
@ -341,7 +353,7 @@ function createRemoteBuildRequest(
headers: { 'Content-Encoding': 'gzip' },
body: tarStream.pipe(zlib.createGzip({ level: 6 })),
})
.on('error', onError)
.once('error', onError) // `.once` because the handler re-emits
.once('response', (response: request.RequestResponse) => {
if (response.statusCode >= 100 && response.statusCode < 400) {
if (DEBUG_MODE) {
@ -357,28 +369,30 @@ function createRemoteBuildRequest(
if (response.body) {
msgArr.push(response.body);
}
onError(new Error(msgArr.join('\n')));
onError(new ExpectedError(msgArr.join('\n')));
}
});
}
async function getRemoteBuildStream(
build: RemoteBuild,
): Promise<NodeJS.ReadWriteStream> {
): Promise<[request.Request, Stream.Stream]> {
const builderUrl = await getBuilderEndpoint(
build.baseUrl,
build.owner,
build.app,
build.appSlug,
build.opts,
);
let stream: Stream.Stream;
let uploadSpinner = {
stop: () => {
/* noop */
},
};
let exitOnError = (error: Error) => {
return exitWithExpectedError(error);
const onError = (error: Error) => {
uploadSpinner.stop();
if (stream) {
stream.emit('error', error);
}
};
// We only show the spinner when outputting to a tty
if (process.stdout.isTTY) {
@ -386,36 +400,26 @@ async function getRemoteBuildStream(
uploadSpinner = new visuals.Spinner(
'Uploading source package to balenaCloud',
);
exitOnError = (error: Error): never => {
uploadSpinner.stop();
return exitWithExpectedError(error);
};
// This is not strongly typed to start with, so we cast
// to any to allow the method call
(uploadSpinner as any).start();
}
try {
const tarStream = await getTarStream(build);
const buildRequest = createRemoteBuildRequest(
build,
tarStream,
builderUrl,
exitOnError,
);
let stream: NodeJS.ReadWriteStream;
if (build.opts.headless) {
stream = (buildRequest as unknown) as NodeJS.ReadWriteStream;
} else {
stream = buildRequest.pipe(JSONStream.parse('*'));
}
return stream
.once('close', () => uploadSpinner.stop())
.once('data', () => uploadSpinner.stop())
.once('end', () => uploadSpinner.stop())
.once('error', () => uploadSpinner.stop())
.once('finish', () => uploadSpinner.stop());
} catch (error) {
return exitOnError(error);
const tarStream = await getTarStream(build);
const buildRequest = createRemoteBuildRequest(
build,
tarStream,
builderUrl,
onError,
);
if (build.opts.headless) {
stream = buildRequest;
} else {
stream = buildRequest.pipe(JSONStream.parse('*'));
}
stream = stream
.once('error', () => uploadSpinner.stop())
.once('close', () => uploadSpinner.stop())
.once('data', () => uploadSpinner.stop())
.once('end', () => uploadSpinner.stop())
.once('finish', () => uploadSpinner.stop());
return [buildRequest, stream];
}

View File

@ -15,7 +15,12 @@
* limitations under the License.
*/
import type { Application, BalenaSDK, PineOptions } from 'balena-sdk';
import type {
Application,
BalenaSDK,
Organization,
PineOptions,
} from 'balena-sdk';
/**
* Wraps the sdk application.get method,
@ -27,10 +32,6 @@ export async function getApplication(
nameOrSlugOrId: string | number,
options?: PineOptions<Application>,
): Promise<Application> {
// TODO: Consider whether it would be useful to generally include interactive selection of application here,
// when nameOrSlugOrId not provided.
// e.g. nameOrSlugOrId || (await (await import('../../utils/patterns')).selectApplication()),
// See commands/device/init.ts ~ln100 for example
const { looksLikeInteger } = await import('./validation');
if (looksLikeInteger(nameOrSlugOrId as string)) {
try {
@ -47,3 +48,67 @@ export async function getApplication(
}
return sdk.models.application.get(nameOrSlugOrId, options);
}
/**
* Given an string representation of an application identifier,
* which could be one of:
* - name (including numeric names)
* - slug
* - numerical id
* disambiguate and return a properly typed identifier.
*
* Attempts to minimise the number of API calls required.
* TODO: Remove this once support for numeric App IDs is removed.
*/
export async function getTypedApplicationIdentifier(
sdk: BalenaSDK,
nameOrSlugOrId: string,
) {
const { looksLikeInteger } = await import('./validation');
// If there's no possible ambiguity,
// return the passed identifier unchanged
if (!looksLikeInteger(nameOrSlugOrId)) {
return nameOrSlugOrId;
}
// Resolve ambiguity
try {
// Test for existence of app with this numerical ID,
// and return typed id if found
return (await sdk.models.application.get(Number(nameOrSlugOrId))).id;
} catch (e) {
const { instanceOf } = await import('../errors');
const { BalenaApplicationNotFound } = await import('balena-errors');
if (!instanceOf(e, BalenaApplicationNotFound)) {
throw e;
}
}
// App with this numerical id not found
// return the passed identifier unchanged
return nameOrSlugOrId;
}
/**
* Wraps the sdk organization.getAll method,
* restricting to those orgs user is a member of
*/
export async function getOwnOrganizations(
sdk: BalenaSDK,
): Promise<Organization[]> {
return await sdk.models.organization.getAll({
$filter: {
organization_membership: {
$any: {
$alias: 'orm',
$expr: {
orm: {
user: await sdk.auth.getUserId(),
},
},
},
},
},
$orderby: 'name asc',
});
}

View File

@ -162,6 +162,7 @@ function sshErrorMessage(exitSignal?: string, exitCode?: number) {
msg.push(`
Are the SSH keys correctly configured in balenaCloud? See:
https://www.balena.io/docs/learn/manage/ssh-access/#add-an-ssh-key-to-balenacloud`);
msg.push('Are you accidentally using `sudo`?');
}
}
return msg.join('\n');

View File

@ -15,10 +15,14 @@ limitations under the License.
*/
import type { BalenaSDK } from 'balena-sdk';
import { Socket } from 'net';
import * as tls from 'tls';
import { TypedError } from 'typed-error';
import { ExpectedError } from '../errors';
const PROXY_CONNECT_TIMEOUT_MS = 10000;
class TunnelServerNotTrustedError extends ExpectedError {}
class UnableToConnectError extends TypedError {
public status: string;
public statusCode: string;
@ -42,17 +46,17 @@ export const tunnelConnectionToDevice = (
sdk: BalenaSDK,
) => {
return Promise.all([
sdk.settings.get('vpnUrl'),
sdk.settings.get('tunnelUrl'),
sdk.auth.whoami(),
sdk.auth.getToken(),
]).then(([vpnUrl, whoami, token]) => {
]).then(([tunnelUrl, whoami, token]) => {
const auth = {
user: whoami || 'root',
password: token,
};
return (client: Socket): Promise<void> =>
openPortThroughProxy(vpnUrl, 3128, auth, uuid, port)
openPortThroughProxy(tunnelUrl, 443, auth, uuid, port)
.then((remote) => {
client.pipe(remote);
remote.pipe(client);
@ -96,30 +100,41 @@ const openPortThroughProxy = (
}
return new Promise<Socket>((resolve, reject) => {
const proxyTunnel = new Socket();
proxyTunnel.on('error', reject);
proxyTunnel.connect(proxyPort, proxyServer, () => {
const proxyConnectionHandler = (data: Buffer) => {
proxyTunnel.removeListener('data', proxyConnectionHandler);
const [httpStatus] = data.toString('utf8').split('\r\n');
const [, httpStatusCode, ...httpMessage] = httpStatus.split(' ');
if (parseInt(httpStatusCode, 10) === 200) {
proxyTunnel.setTimeout(0);
resolve(proxyTunnel);
} else {
const proxyTunnel = tls.connect(
proxyPort,
proxyServer,
{ servername: proxyServer }, // send the hostname in the SNI field
() => {
if (!proxyTunnel.authorized) {
console.error('Unable to authorize the tunnel server');
reject(
new UnableToConnectError(httpStatusCode, httpMessage.join(' ')),
new TunnelServerNotTrustedError(proxyTunnel.authorizationError),
);
return;
}
};
proxyTunnel.on('timeout', () => {
reject(new RemoteSocketNotListening(devicePort));
});
proxyTunnel.on('data', proxyConnectionHandler);
proxyTunnel.setTimeout(PROXY_CONNECT_TIMEOUT_MS);
proxyTunnel.write(httpHeaders.join('\r\n').concat('\r\n\r\n'));
});
proxyTunnel.once('data', (data: Buffer) => {
const [httpStatus] = data.toString('utf8').split('\r\n');
const [, httpStatusCode, ...httpMessage] = httpStatus.split(' ');
if (parseInt(httpStatusCode, 10) === 200) {
proxyTunnel.setTimeout(0);
resolve(proxyTunnel);
} else {
reject(
new UnableToConnectError(httpStatusCode, httpMessage.join(' ')),
);
}
});
proxyTunnel.on('timeout', () => {
reject(new RemoteSocketNotListening(devicePort));
});
proxyTunnel.setTimeout(PROXY_CONNECT_TIMEOUT_MS);
proxyTunnel.write(httpHeaders.join('\r\n').concat('\r\n\r\n'));
},
);
proxyTunnel.on('error', reject);
});
};

View File

@ -117,3 +117,8 @@ export function parseAsLocalHostnameOrIp(input: string, paramName?: string) {
return input;
}
export function looksLikeAppSlug(input: string) {
// One or more non whitespace chars, /, 4 or more non whitespace chars
return /[\S]+\/[\S]{4,}/.test(input);
}

View File

@ -22,13 +22,12 @@ export function isVersionGTE(v: string): boolean {
return semver.gte(process.env.BALENA_CLI_VERSION_OVERRIDE || version, v);
}
let v12: boolean;
let v13: boolean;
export function isV12(): boolean {
if (v12 === undefined) {
// This is the `Change-type: major` PR that will produce v12.0.0.
// Enable the v12 feature switches and run all v12 tests.
v12 = true; // v12 = isVersionGTE('12.0.0');
/** Feature switch for the next major version of the CLI */
export function isV13(): boolean {
if (v13 === undefined) {
v13 = isVersionGTE('13.0.0');
}
return v12;
return v13;
}

211
npm-shrinkwrap.json generated
View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "12.29.0",
"version": "12.42.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1673,9 +1673,9 @@
"integrity": "sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w=="
},
"@types/memoizee": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.4.tgz",
"integrity": "sha512-c9+1g6+6vEqcw5UuM0RbfQV0mssmZcoG9+hNC5ptDCsv4G+XJW1Z4pE13wV5zbc9e0+YrDydALBTiD3nWG1a3g=="
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.5.tgz",
"integrity": "sha512-+ZzZZ3+0a7/ajBPeAAD4+LxrBsCat0EFZQtO3o0rwpIeLmDmSaM8KF/oYPuFxeUFAMiHIHFcGucFnY/8S4Hszg=="
},
"@types/mime": {
"version": "2.0.3",
@ -1995,9 +1995,9 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
},
"abortcontroller-polyfill": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.5.0.tgz",
"integrity": "sha512-O6Xk757Jb4o0LMzMOMdWvxpHWrQzruYBaUruFaIOfAQRnWFxfdXYobw12jrVHGtoXk6WiiyYzc0QWN9aL62HQA=="
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.1.tgz",
"integrity": "sha512-yml9NiDEH4M4p0G4AcPkg8AAa4mF3nfYF28VQxaokpO67j9H7gWgmsVWJ/f1Rn+PzsnDYvzJzWIQzCqDKRvWlA=="
},
"accepts": {
"version": "1.3.7",
@ -2525,13 +2525,13 @@
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"balena-auth": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balena-auth/-/balena-auth-4.0.2.tgz",
"integrity": "sha512-a0IfAN53aQpFOKtgKK+MSLMVZC/HsHZLiDsJhpPKTUd257fEcnmQWzBYxot9ny9NfJhhhoyalcu5e4RSH0TsiQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/balena-auth/-/balena-auth-4.1.0.tgz",
"integrity": "sha512-weKEmWnlb2cYNLr8pqaVdCP+uTfxBLQU2oHzn6M9mlF6+mqIaQTmkj+/8fMilEnm32qhYYdOyz4y3H+7kLIcIw==",
"requires": {
"@types/jwt-decode": "^2.2.1",
"balena-errors": "^4.2.1",
"balena-settings-storage": "^6.0.0",
"balena-errors": "^4.7.1",
"balena-settings-storage": "^7.0.0",
"jwt-decode": "^2.2.0",
"tslib": "^2.0.0"
}
@ -2562,9 +2562,9 @@
}
},
"balena-errors": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/balena-errors/-/balena-errors-4.4.1.tgz",
"integrity": "sha512-912lPp1LyBjkpxRg6m/EpOCssqMhgkzyYbrKwtT2uRvixm89WOlJrj5sPkxnbPnp5IoMNaoRONxFt1jtiQf50Q==",
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/balena-errors/-/balena-errors-4.7.1.tgz",
"integrity": "sha512-g21kf6N5tMZYDietZNLHCbqhmAxPX9gRmJQgMuIjMZWvjzCQxcqaELNYTtDwXwEbXLhbhF6QV2IJDZul+5X6nQ==",
"requires": {
"tslib": "^2.0.0",
"typed-error": "^3.0.0"
@ -2610,20 +2610,30 @@
}
},
"balena-pine": {
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/balena-pine/-/balena-pine-12.3.0.tgz",
"integrity": "sha512-HKC/7Aqfd4YEqx8y2/KfhzrYHW68i3rhbGeTMRCFluLAzsg9YvzBjvLFHLaIyv4IBLqwgsEkjxCpz1Qzyq3XUw==",
"version": "12.4.0",
"resolved": "https://registry.npmjs.org/balena-pine/-/balena-pine-12.4.0.tgz",
"integrity": "sha512-ap4WvIwwEsLl5rh7badxiu/4pDqbgT3DdeSkKl03T9U4XQ8fgFuqcl//lvpUG1jzhSe/ExQfkv1ZebrUd/kvqA==",
"requires": {
"@balena/es-version": "^1.0.0",
"balena-errors": "^4.2.1",
"pinejs-client-core": "^6.6.1",
"pinejs-client-core": "^6.9.0",
"tslib": "^2.0.1"
},
"dependencies": {
"pinejs-client-core": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/pinejs-client-core/-/pinejs-client-core-6.9.4.tgz",
"integrity": "sha512-saScuq6J3NIjOvTeHUVZSK/pxF+uwgxxbBjffN2WUUpkz846SGGFzKv89Y73FRuP5bT25gFHJ4W4ZabXOmqI5A==",
"requires": {
"@balena/es-version": "^1.0.0"
}
}
}
},
"balena-preload": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/balena-preload/-/balena-preload-10.3.1.tgz",
"integrity": "sha512-pz0IRzi2ByjgGROO9ryMlRI24RIp1IMfx7zcLyf99cOveOPFwCgu0N2CE8kkq9hnmlJuqqDoOYLqkoTJ5zv8xw==",
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/balena-preload/-/balena-preload-10.4.1.tgz",
"integrity": "sha512-/DHvtF7qPg3cfHfZxP3+EInqtqlwD/czTyIxBMnieZb/4UMISL/6fXPFsVYhxTwAeNmTsBaH+KTj4Owb5Lz5AA==",
"requires": {
"archiver": "^3.1.1",
"balena-sdk": "^15.3.1",
@ -2635,6 +2645,7 @@
"get-port": "^3.2.0",
"lodash": "^4.17.20",
"node-cleanup": "^2.1.2",
"request-promise": "^4.2.6",
"resin-cli-visuals": "^1.7.0",
"tar-fs": "^2.1.0",
"tmp": "0.0.33",
@ -2698,13 +2709,13 @@
}
},
"balena-request": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/balena-request/-/balena-request-11.1.1.tgz",
"integrity": "sha512-PsIbPtEOo84E8AxlUbyuEnnX3yd7A0SGFW1T/L7QcVlxQPPMgWW0SdPU94bZAT7futBxZ+ha7yFKHJ3VlO7uIg==",
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/balena-request/-/balena-request-11.4.0.tgz",
"integrity": "sha512-wfPaWX/+NgT2xNplQqA8oCNLJXG6eLMbf9IOX8T4ZX+nqBoA9bydoIRLunGExMNfUWpxApvBh5ls8fJOd9VTjQ==",
"requires": {
"@balena/node-web-streams": "^0.2.3",
"balena-errors": "^4.4.0",
"fetch-ponyfill": "^6.1.1",
"balena-errors": "^4.7.1",
"fetch-ponyfill": "^7.1.0",
"fetch-readablestream": "^0.2.0",
"progress-stream": "^2.0.0",
"qs": "^6.9.4",
@ -2712,21 +2723,21 @@
}
},
"balena-sdk": {
"version": "15.6.0",
"resolved": "https://registry.npmjs.org/balena-sdk/-/balena-sdk-15.6.0.tgz",
"integrity": "sha512-yurPtF7+loQVcoPfWjJQV9WziMaLUxvppGZPOVH5GVOoWYjQSaO6avtOw97VLmMglufQIV+PN/BoseIRG7XxXg==",
"version": "15.29.0",
"resolved": "https://registry.npmjs.org/balena-sdk/-/balena-sdk-15.29.0.tgz",
"integrity": "sha512-7m5Auj5Xus5dvXC7yzJgT1a9P/fIDlK/R7c6l6X3ISL8nuhR5ZKu4SmnJnmEIOvBMHsCjDqVn76CxYu3tDIm9g==",
"requires": {
"@balena/es-version": "^1.0.0",
"@types/lodash": "^4.14.159",
"@types/memoizee": "^0.4.3",
"@types/node": "^10.17.28",
"abortcontroller-polyfill": "^1.5.0",
"balena-auth": "^4.0.2",
"balena-errors": "^4.4.0",
"balena-auth": "^4.1.0",
"balena-errors": "^4.7.1",
"balena-hup-action-utils": "~4.0.2",
"balena-pine": "^12.3.0",
"balena-pine": "^12.4.0",
"balena-register-device": "^7.1.0",
"balena-request": "^11.1.1",
"balena-request": "^11.2.0",
"balena-semver": "^2.3.0",
"balena-settings-client": "^4.0.4",
"lodash": "^4.17.19",
@ -2749,9 +2760,9 @@
}
},
"balena-settings-client": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/balena-settings-client/-/balena-settings-client-4.0.5.tgz",
"integrity": "sha512-w1SWIQYViMP51PYnPvbwgGavipkBv8wbRj1ISjPYZ5M45oEVRcktDfix8c3xOlWl+vWqW8aA4L8BjhqnxhAvRQ==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/balena-settings-client/-/balena-settings-client-4.0.6.tgz",
"integrity": "sha512-bB14Zvg1N6t7XXPJqZs48SajgTuk2WTMm2AnxcOfoIQ2d/Lh0RsEGxD9toF2v+WhF2Ip4u7ko5tKlCr2kFddXA==",
"requires": {
"@resin.io/types-hidepath": "1.0.1",
"@resin.io/types-home-or-tmp": "3.0.0",
@ -2771,11 +2782,12 @@
}
},
"balena-settings-storage": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/balena-settings-storage/-/balena-settings-storage-6.0.1.tgz",
"integrity": "sha512-jdDoKzbJXlF696EZSbwD6lZ1dMe98aUtx7btFE4j4PRCSeh2BWx5P5VLGh9Bk3sH2FUcqYg0iw/wdKvkcv44oA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/balena-settings-storage/-/balena-settings-storage-7.0.0.tgz",
"integrity": "sha512-gufzVJznyt9e1CvpBuLe2caU5KcEwl1YHCbK5OMz09zXDA2OMAICPXsLlViK+KiuZwZrBx3tyU2FZjAzRZFgwQ==",
"requires": {
"@types/node": "^10.17.26",
"balena-errors": "^4.7.1",
"tslib": "^2.0.0"
}
},
@ -3202,9 +3214,9 @@
"integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g=="
},
"buffer-indexof-polyfill": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.1.tgz",
"integrity": "sha1-qfuAbOgUXVQoUQznLyeLs2OmOL8="
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="
},
"buffer-shims": {
"version": "1.0.0",
@ -3278,6 +3290,15 @@
}
}
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
}
},
"call-me-maybe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
@ -6175,11 +6196,18 @@
}
},
"fetch-ponyfill": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-6.1.1.tgz",
"integrity": "sha512-rWLgTr5A44/XhvCQPYj0X9Tc+cjUaHofSM4lcwjc9MavD5lkjIhJ+h8JQlavPlTIgDpwhuRozaIykBvX9ItaSA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz",
"integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==",
"requires": {
"node-fetch": "~2.6.0"
"node-fetch": "~2.6.1"
},
"dependencies": {
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
}
}
},
"fetch-readablestream": {
@ -6916,6 +6944,16 @@
"integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=",
"dev": true
},
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1"
}
},
"get-port": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz",
@ -10057,18 +10095,18 @@
}
},
"memoizee": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.14.tgz",
"integrity": "sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg==",
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz",
"integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==",
"requires": {
"d": "1",
"es5-ext": "^0.10.45",
"es6-weak-map": "^2.0.2",
"d": "^1.0.1",
"es5-ext": "^0.10.53",
"es6-weak-map": "^2.0.3",
"event-emitter": "^0.3.5",
"is-promise": "^2.1",
"lru-queue": "0.1",
"next-tick": "1",
"timers-ext": "^0.1.5"
"is-promise": "^2.2.2",
"lru-queue": "^0.1.0",
"next-tick": "^1.1.0",
"timers-ext": "^0.1.7"
}
},
"meow": {
@ -10999,7 +11037,8 @@
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
"dev": true
},
"node-gyp-build": {
"version": "4.2.3",
@ -12780,9 +12819,12 @@
}
},
"qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.0.tgz",
"integrity": "sha512-yjACOWijC6L/kmPZZAsVBNY2zfHSIbpdpL977quseu56/8BZ2LoF5axK2bGhbzhVKt7V9xgWTtpyLbxwIoER0Q==",
"requires": {
"side-channel": "^1.0.4"
}
},
"randombytes": {
"version": "2.1.0",
@ -13321,6 +13363,25 @@
"throttleit": "^1.0.0"
}
},
"request-promise": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz",
"integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==",
"requires": {
"bluebird": "^3.5.0",
"request-promise-core": "1.1.4",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
}
},
"request-promise-core": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz",
"integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==",
"requires": {
"lodash": "^4.17.19"
}
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -14023,6 +14084,23 @@
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"requires": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
},
"dependencies": {
"object-inspect": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz",
"integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw=="
}
}
},
"signal-exit": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
@ -14517,6 +14595,11 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks="
},
"stream-chunker": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/stream-chunker/-/stream-chunker-1.2.8.tgz",
@ -15807,9 +15890,9 @@
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
},
"uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ=="
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"v8-compile-cache": {
"version": "2.2.0",

View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "12.29.0",
"version": "12.42.0",
"description": "The official balena Command Line Interface",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
@ -84,7 +84,8 @@
"author": "Juan Cruz Viotti <juan@balena.io>",
"license": "Apache-2.0",
"engines": {
"node": ">=10.20.0"
"node": ">=10.20.0 <13.0.0",
"npm": "<7.0.0"
},
"husky": {
"hooks": {
@ -101,7 +102,7 @@
},
"macos": {
"identifier": "io.balena.cli",
"sign": "Developer ID Installer: Rulemotion Ltd (66H43P8FRG)"
"sign": "Developer ID Installer: Balena Ltd (66H43P8FRG)"
},
"plugins": [
"@oclif/plugin-help"
@ -197,14 +198,14 @@
"JSONStream": "^1.0.3",
"balena-config-json": "^4.1.0",
"balena-device-init": "^5.0.2",
"balena-errors": "^4.4.1",
"balena-errors": "^4.7.1",
"balena-image-manager": "^7.0.3",
"balena-preload": "^10.3.1",
"balena-preload": "^10.4.1",
"balena-release": "^3.0.0",
"balena-sdk": "^15.6.0",
"balena-sdk": "15.29.0",
"balena-semver": "^2.3.0",
"balena-settings-client": "^4.0.5",
"balena-settings-storage": "^6.0.1",
"balena-settings-client": "^4.0.6",
"balena-settings-storage": "^7.0.0",
"balena-sync": "^11.0.2",
"bluebird": "^3.7.2",
"body-parser": "^1.19.0",

View File

@ -0,0 +1,17 @@
diff --git a/node_modules/exit-hook/index.js b/node_modules/exit-hook/index.js
index e18013f..3366356 100644
--- a/node_modules/exit-hook/index.js
+++ b/node_modules/exit-hook/index.js
@@ -14,9 +14,9 @@ function exit(exit, signal) {
el();
});
- if (exit === true) {
- process.exit(128 + signal);
- }
+ // if (exit === true) {
+ // process.exit(128 + signal);
+ // }
};
module.exports = function (cb) {

View File

@ -185,6 +185,7 @@ export class BalenaAPIMock extends NockMock {
public expectGetDevice(opts: {
fullUUID: string;
inaccessibleApp?: boolean;
isOnline?: boolean;
optional?: boolean;
persist?: boolean;
}) {
@ -194,6 +195,7 @@ export class BalenaAPIMock extends NockMock {
{
id,
uuid: opts.fullUUID,
is_online: opts.isOnline,
belongs_to__application: opts.inaccessibleApp
? []
: [{ app_name: 'test' }],

View File

@ -257,12 +257,16 @@ describe('balena build', function () {
const qemuMod = require(qemuModPath);
const qemuBinPath = await qemuMod.getQemuPath(arch);
try {
// patch fs.access and fs.stat to pretend that a copy of the Qemu binary
// already exists locally, thus preventing a download during tests
mock(fsModPath, {
...fsMod,
promises: {
...fsMod.promises,
access: async (p: string) =>
p === qemuBinPath ? undefined : fsMod.promises.access(p),
stat: async (p: string) =>
p === qemuBinPath ? { size: 1 } : fsMod.promises.stat(p),
},
});
mock(qemuModPath, {

View File

@ -19,32 +19,6 @@ import { expect } from 'chai';
import { BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
const HELP_RESPONSE = `
Move one or more devices to another application.
USAGE
$ balena device move <uuid(s)>
ARGUMENTS
<uuid> comma-separated list (no blank spaces) of device UUIDs to be moved
OPTIONS
-a, --application <application> application name
-h, --help show CLI help
--app <app> same as '--application'
DESCRIPTION
Move one or more devices to another application.
Note, if the application option is omitted it will be prompted
for interactively.
EXAMPLES
$ balena device move 7cf02a6
$ balena device move 7cf02a6,dc39e52
$ balena device move 7cf02a6 --application MyNewApp
`;
describe('balena device move', function () {
let api: BalenaAPIMock;
@ -59,14 +33,6 @@ describe('balena device move', function () {
api.done();
});
it('should print help text with the -h flag', async () => {
const { out, err } = await runCommand('device move -h');
expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE]));
expect(err).to.eql([]);
});
it('should error if uuid not provided', async () => {
const { out, err } = await runCommand('device move');
const errLines = cleanOutput(err);

View File

@ -21,36 +21,6 @@ import * as path from 'path';
import { apiResponsePath, BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
const HELP_RESPONSE = `
List all devices.
USAGE
$ balena devices
OPTIONS
-a, --application <application> application name
-h, --help show CLI help
-j, --json produce JSON output instead of tabular output
--app <app> same as '--application'
DESCRIPTION
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/).
EXAMPLES
$ balena devices
$ balena devices --application MyApp
$ balena devices --app MyApp
$ balena devices -a MyApp
`;
describe('balena devices', function () {
let api: BalenaAPIMock;
@ -65,14 +35,6 @@ describe('balena devices', function () {
api.done();
});
it('should print help text with the -h flag', async () => {
const { out, err } = await runCommand('devices -h');
expect(cleanOutput(out)).to.deep.equal(cleanOutput([HELP_RESPONSE]));
expect(err).to.eql([]);
});
it('should list devices from own and collaborator apps', async () => {
api.scope
.get(

View File

@ -17,7 +17,6 @@
import { expect } from 'chai';
import { isV12 } from '../../../build/utils/version';
import { BalenaAPIMock } from '../../balena-api-mock';
import { cleanOutput, runCommand } from '../../helpers';
@ -50,9 +49,7 @@ describe('balena devices supported', function () {
const lines = cleanOutput(out);
expect(lines[0].replace(/ +/g, ' ')).to.equal(
isV12() ? 'SLUG ALIASES ARCH NAME' : 'SLUG NAME',
);
expect(lines[0].replace(/ +/g, ' ')).to.equal('SLUG ALIASES ARCH NAME');
expect(lines).to.have.lengthOf.at.least(2);
// Discontinued devices should be filtered out from results

View File

@ -18,7 +18,6 @@
import { expect } from 'chai';
import { stripIndent } from '../../../lib/utils/lazy';
import { isV12 } from '../../../lib/utils/version';
import { BalenaAPIMock } from '../../balena-api-mock';
import { runCommand } from '../../helpers';
@ -43,38 +42,22 @@ describe('balena envs', function () {
});
it('should successfully list env vars for a test app', async () => {
if (isV12()) {
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
const { out, err } = await runCommand(`envs -a ${appName}`);
const { out, err } = await runCommand(`envs -a ${appName}`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test service1
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
} else {
api.expectGetApplication();
api.expectGetAppEnvVars();
const { out, err } = await runCommand(`envs -a ${appName}`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120101 var1 var1-val
120102 var2 22
` + '\n',
);
expect(err.join('')).to.equal('');
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test service1
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
});
it('should successfully list config vars for a test app', async () => {
@ -83,21 +66,12 @@ describe('balena envs', function () {
const { out, err } = await runCommand(`envs -a ${appName} --config`);
if (isV12()) {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false test
` + '\n',
);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false
` + '\n',
);
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false test
` + '\n',
);
expect(err.join('')).to.equal('');
});
@ -108,24 +82,14 @@ describe('balena envs', function () {
const { out, err } = await runCommand(`envs -cja ${appName}`);
if (isV12()) {
expect(JSON.parse(out.join(''))).to.deep.equal([
{
appName: 'test',
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
]);
} else {
expect(JSON.parse(out.join(''))).to.deep.equal([
{
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
]);
}
expect(JSON.parse(out.join(''))).to.deep.equal([
{
appName: 'test',
id: 120300,
name: 'RESIN_SUPERVISOR_NATIVE_LOGGER',
value: 'false',
},
]);
expect(err.join('')).to.equal('');
});
@ -133,138 +97,70 @@ describe('balena envs', function () {
const serviceName = 'service2';
api.expectGetService({ serviceName });
api.expectGetApplication();
if (isV12()) {
api.expectGetAppEnvVars();
}
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
const { out, err } = await runCommand(
`envs -a ${appName} -s ${serviceName}`,
);
if (isV12()) {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120111 svar2 svar2-value
` + '\n',
);
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
});
if (!isV12()) {
it('should produce an empty JSON array when no app service variables exist', async () => {
const serviceName = 'nono';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppServiceVars();
it('should successfully list env and service vars for a test app (-s flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
const { out, err } = await runCommand(
`envs -a ${appName} -s ${serviceName} -j`,
);
const { out, err } = await runCommand(
`envs -a ${appName} -s ${serviceName}`,
);
expect(out.join('')).to.equal('[]\n');
expect(err.join('')).to.equal('');
});
}
if (!isV12()) {
it('should successfully list env and service vars for a test app (--all flag)', async () => {
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
const { out, err } = await runCommand(`envs -a ${appName} --all`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test service1
120111 svar2 svar2-value test service2
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
});
}
it(
isV12()
? 'should successfully list env and service vars for a test app (-s flags)'
: 'should successfully list env and service vars for a test app (--all -s flags)',
async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
const { out, err } = await runCommand(
isV12()
? `envs -a ${appName} -s ${serviceName}`
: `envs -a ${appName} --all -s ${serviceName}`,
);
expect(out.join('')).to.equal(
stripIndent`
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION SERVICE
120110 svar1 svar1-value test ${serviceName}
120101 var1 var1-val test *
120102 var2 22 test *
` + '\n',
);
expect(err.join('')).to.equal('');
},
);
);
expect(err.join('')).to.equal('');
});
it('should successfully list env variables for a test device', async () => {
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
if (isV12()) {
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceServiceVars();
}
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid}`);
if (isV12()) {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * service1
120111 svar2 svar2-value test * service2
120120 svar3 svar3-value test ${uuid} service1
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120203 var3 var3-val
120204 var4 44
` + '\n',
);
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * service1
120111 svar2 svar2-value test * service2
120120 svar3 svar3-value test ${uuid} service1
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
});
@ -272,42 +168,25 @@ describe('balena envs', function () {
it('should successfully list env variables for a test device (JSON output)', async () => {
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
if (isV12()) {
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceServiceVars();
}
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceServiceVars();
const { out, err } = await runCommand(`envs -jd ${shortUUID}`);
if (isV12()) {
expect(JSON.parse(out.join(''))).to.deep.equal(
JSON.parse(`[
{ "id": 120101, "appName": "test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "appName": "test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "appName": "test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "service1" },
{ "id": 120111, "appName": "test", "deviceUUID": "*", "name": "svar2", "value": "svar2-value", "serviceName": "service2" },
{ "id": 120120, "appName": "test", "deviceUUID": "${fullUUID}", "name": "svar3", "value": "svar3-value", "serviceName": "service1" },
{ "id": 120121, "appName": "test", "deviceUUID": "${fullUUID}", "name": "svar4", "value": "svar4-value", "serviceName": "service2" },
{ "id": 120203, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var3", "value": "var3-val", "serviceName": "*" },
{ "id": 120204, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`),
);
} else {
expect(JSON.parse(out.join(''))).to.deep.equal([
{
id: 120203,
name: 'var3',
value: 'var3-val',
},
{
id: 120204,
name: 'var4',
value: '44',
},
]);
}
expect(JSON.parse(out.join(''))).to.deep.equal(
JSON.parse(`[
{ "id": 120101, "appName": "test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "appName": "test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "appName": "test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "service1" },
{ "id": 120111, "appName": "test", "deviceUUID": "*", "name": "svar2", "value": "svar2-value", "serviceName": "service2" },
{ "id": 120120, "appName": "test", "deviceUUID": "${fullUUID}", "name": "svar3", "value": "svar3-value", "serviceName": "service1" },
{ "id": 120121, "appName": "test", "deviceUUID": "${fullUUID}", "name": "svar4", "value": "svar4-value", "serviceName": "service2" },
{ "id": 120203, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var3", "value": "var3-val", "serviceName": "*" },
{ "id": 120204, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`),
);
expect(err.join('')).to.equal('');
});
@ -315,29 +194,18 @@ describe('balena envs', function () {
it('should successfully list config variables for a test device', async () => {
api.expectGetDevice({ fullUUID });
api.expectGetDeviceConfigVars();
if (isV12()) {
api.expectGetApplication();
api.expectGetAppConfigVars();
}
api.expectGetApplication();
api.expectGetAppConfigVars();
const { out, err } = await runCommand(`envs -d ${shortUUID} --config`);
if (isV12()) {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false test *
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900 test ${shortUUID}
` + '\n',
);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900
` + '\n',
);
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE
120300 RESIN_SUPERVISOR_NATIVE_LOGGER false test *
120400 RESIN_SUPERVISOR_POLL_INTERVAL 900900 test ${shortUUID}
` + '\n',
);
expect(err.join('')).to.equal('');
});
@ -348,85 +216,28 @@ describe('balena envs', function () {
api.expectGetApplication();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceServiceVars();
if (isV12()) {
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceEnvVars();
}
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDeviceEnvVars();
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid} -s ${serviceName}`);
if (isV12()) {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120111 svar2 svar2-value test * service2
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
} else {
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE
120121 svar4 svar4-value
` + '\n',
);
}
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120111 svar2 svar2-value test * service2
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
});
if (!isV12()) {
it('should produce an empty JSON array when no device service variables exist', async () => {
const serviceName = 'nono';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceServiceVars();
const { out, err } = await runCommand(
`envs -d ${shortUUID} -s ${serviceName} -j`,
);
expect(out.join('')).to.equal('[]\n');
expect(err.join('')).to.equal('');
});
}
if (!isV12()) {
it('should successfully list env and service variables for a test device (--all flag)', async () => {
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
api.expectGetDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid} --all`);
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * service1
120111 svar2 svar2-value test * service2
120120 svar3 svar3-value test ${uuid} service1
120121 svar4 svar4-value test ${uuid} service2
120101 var1 var1-val test * *
120102 var2 22 test * *
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
});
}
it('should successfully list env and service variables for a test device (unknown app)', async () => {
api.expectGetDevice({ fullUUID, inaccessibleApp: true });
api.expectGetDeviceEnvVars();
@ -434,9 +245,7 @@ describe('balena envs', function () {
const uuid = shortUUID;
const { out, err } = await runCommand(
isV12() ? `envs -d ${uuid}` : `envs -d ${uuid} --all`,
);
const { out, err } = await runCommand(`envs -d ${uuid}`);
expect(out.join('')).to.equal(
stripIndent`
@ -450,29 +259,21 @@ describe('balena envs', function () {
expect(err.join('')).to.equal('');
});
it(
isV12()
? 'should successfully list env and service vars for a test device (-s flags)'
: 'should successfully list env and service vars for a test device (--all -s flags)',
async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
api.expectGetDeviceServiceVars();
it('should successfully list env and service vars for a test device (-s flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
api.expectGetDeviceServiceVars();
const uuid = shortUUID;
const { out, err } = await runCommand(
isV12()
? `envs -d ${uuid} -s ${serviceName}`
: `envs -d ${uuid} --all -s ${serviceName}`,
);
const uuid = shortUUID;
const { out, err } = await runCommand(`envs -d ${uuid} -s ${serviceName}`);
expect(out.join('')).to.equal(
stripIndent`
expect(out.join('')).to.equal(
stripIndent`
ID NAME VALUE APPLICATION DEVICE SERVICE
120110 svar1 svar1-value test * ${serviceName}
120120 svar3 svar3-value test ${uuid} ${serviceName}
@ -481,33 +282,26 @@ describe('balena envs', function () {
120203 var3 var3-val test ${uuid} *
120204 var4 44 test ${uuid} *
` + '\n',
);
expect(err.join('')).to.equal('');
},
);
);
expect(err.join('')).to.equal('');
});
it(
isV12()
? 'should successfully list env and service vars for a test device (-js flags)'
: 'should successfully list env and service vars for a test device (--all -js flags)',
async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
api.expectGetDeviceServiceVars();
it('should successfully list env and service vars for a test device (-js flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
api.expectGetDevice({ fullUUID });
api.expectGetDeviceEnvVars();
api.expectGetDeviceServiceVars();
const { out, err } = await runCommand(
isV12()
? `envs -d ${shortUUID} -js ${serviceName}`
: `envs -d ${shortUUID} --all -js ${serviceName}`,
);
const { out, err } = await runCommand(
`envs -d ${shortUUID} -js ${serviceName}`,
);
expect(JSON.parse(out.join(''))).to.deep.equal(
JSON.parse(`[
expect(JSON.parse(out.join(''))).to.deep.equal(
JSON.parse(`[
{ "id": 120101, "appName": "test", "deviceUUID": "*", "name": "var1", "value": "var1-val", "serviceName": "*" },
{ "id": 120102, "appName": "test", "deviceUUID": "*", "name": "var2", "value": "22", "serviceName": "*" },
{ "id": 120110, "appName": "test", "deviceUUID": "*", "name": "svar1", "value": "svar1-value", "serviceName": "${serviceName}" },
@ -515,8 +309,7 @@ describe('balena envs', function () {
{ "id": 120203, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var3", "value": "var3-val", "serviceName": "*" },
{ "id": 120204, "appName": "test", "deviceUUID": "${fullUUID}", "name": "var4", "value": "44", "serviceName": "*" }
]`),
);
expect(err.join('')).to.equal('');
},
);
);
expect(err.join('')).to.equal('');
});
});

View File

@ -46,20 +46,33 @@ describe('balena logs', function () {
itS('should reach the expected endpoints on a local device', async () => {
supervisor.expectGetPing();
supervisor.expectGetLogs();
supervisor.expectGetLogs();
const { err, out } = await runCommand('logs 1.2.3.4');
const { err, out } = await runCommand('logs 1.2.3.4 --max-retry 1');
expect(err).to.be.empty;
const errLines = cleanOutput(err, true);
const errMsg =
'Max retry count (1) exceeded while attempting to reconnect to the device';
if (process.env.DEBUG) {
expect(errLines).to.include(errMsg);
} else {
expect(errLines).to.have.members([errMsg]);
}
const removeTimestamps = (logLine: string) =>
logLine.replace(/(?<=\[Logs\]) \[.+?\]/, '');
const cleanedOut = cleanOutput(out, true).map((l) => removeTimestamps(l));
expect(cleanedOut).to.deep.equal([
expect(cleanedOut).to.have.members([
'[Logs] Streaming logs',
'[Logs] [bar] bar 8 (332) Linux 4e3f81149d71 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux',
'[Logs] [foo] foo 8 (200) Linux cc5df60d89ee 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux',
'[Error] Connection to device lost',
'[Warn] Connection to device lost',
'Retrying "Streaming logs" after 1.0s (1 of 1) due to: DeviceConnectionLostError: Connection to device lost',
'[Logs] Streaming logs',
'[Logs] [bar] bar 8 (332) Linux 4e3f81149d71 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux',
'[Logs] [foo] foo 8 (200) Linux cc5df60d89ee 4.19.75 #1 SMP PREEMPT Mon Mar 23 11:50:49 UTC 2020 aarch64 GNU/Linux',
'[Warn] Connection to device lost',
]);
});
});

View File

@ -66,8 +66,7 @@ const commonResponseLines = {
};
const commonQueryParams = [
['owner', 'bob'],
['app', 'testApp'],
['slug', 'gh_user/testApp'],
['dockerfilePath', ''],
['emulated', 'false'],
['nocache', 'false'],
@ -87,7 +86,7 @@ describe('balena push', function () {
builder = new BuilderMock();
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true });
api.expectGetMyApplication();
api.expectGetApplication();
});
this.afterEach(() => {
@ -145,7 +144,7 @@ describe('balena push', function () {
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp --source ${projectPath} -R ${regSecretsPath} -G`,
commandLine: `push testApp --source ${projectPath} -R ${regSecretsPath}`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines,
@ -345,7 +344,7 @@ describe('balena push', function () {
await testPushBuildStream({
builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -G`,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath}`,
expectedFiles,
expectedQueryParams: commonQueryParams,
expectedResponseLines: commonResponseLines[responseFilename],

View File

@ -66,11 +66,11 @@ describe('balena ssh', function () {
itSS('should succeed (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234';
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true });
api.expectGetDevice({ fullUUID: deviceUUID });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
mockedExitCode = 0;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
expect(err).to.be.empty;
expect(out).to.be.empty;
});
@ -90,8 +90,7 @@ describe('balena ssh', function () {
'Warning: ssh process exited with non-zero code "255"',
];
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetApplication({ notFound: true });
api.expectGetDevice({ fullUUID: deviceUUID });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: true });
mockedExitCode = 255;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
@ -114,12 +113,32 @@ describe('balena ssh', function () {
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
expect(out).to.be.empty;
});
it('should fail if device not online (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234';
const expectedErrLines = ['Device with UUID abc1234 is offline'];
api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetDevice({ fullUUID: deviceUUID, isOnline: false });
mockedExitCode = 0;
const { err, out } = await runCommand(`ssh ${deviceUUID}`);
expect(cleanOutput(err, true)).to.include.members(expectedErrLines);
expect(out).to.be.empty;
});
});
/** Check whether the 'ssh' tool (executable) exists in the PATH */
async function checkSsh(): Promise<boolean> {
const { which } = await import('../../build/utils/helpers');
const sshPath = await which('ssh', false);
if ((sshPath || '').includes('\\Windows\\System32\\OpenSSH\\ssh')) {
// don't use Windows' built-in ssh tool for these test cases
// because it messes up with the terminal window such that
// "line breaks stop working" (and not even '\033c' fixes it)
// and all mocha output gets printed on a single very long line...
return false;
}
return !!sshPath;
}
@ -127,11 +146,13 @@ async function checkSsh(): Promise<boolean> {
async function startMockSshServer(): Promise<[Server, number]> {
const server = createServer((c) => {
// 'connection' listener
c.on('end', () => {
const handler = (msg: string) => {
if (process.env.DEBUG) {
console.error('[debug] mock ssh server: client disconnected');
console.error(`[debug] mock ssh server: ${msg}`);
}
});
};
c.on('error', (err) => handler(err.message));
c.on('end', () => handler('client disconnected'));
c.end();
});
server.on('error', (err) => {

View File

@ -99,6 +99,18 @@ describe('handleError() function', () => {
expect(printExpectedErrorMessage.notCalled);
});
it('should process thrown strings correctly', async () => {
const error = 'an thrown string';
await ErrorsModule.handleError(error);
expect(printErrorMessage.calledOnce).to.be.true;
expect(printErrorMessage.getCall(0).args[0]).to.equal(error);
expect(captureException.calledOnce).to.be.true;
expect(processExit.calledOnce).to.be.true;
expect(printExpectedErrorMessage.notCalled);
});
it('should process unexpected errors correctly (debug)', async () => {
sandbox.stub(process, 'env').value({ DEBUG: true });

View File

@ -17,6 +17,11 @@
},
"__id": 43699
},
"organization": [
{
"handle": "gh_user"
}
],
"depends_on__application": null,
"actor": 3423895,
"app_name": "testApp",