Compare commits

...

274 Commits

Author SHA1 Message Date
ae18df6710 Fix Windows installer build in new balena CI workers (qqjs patch)
Change-type: patch
2020-12-01 15:19:26 +00:00
8101ab38a6 Fix 'balena ssh' test cases when using the Windows built-in ssh tool
Change-type: patch
2020-12-01 15:19:26 +00:00
0bae6546f2 v12.29.0 2020-12-01 16:07:38 +02:00
40ab27df26 Merge pull request #2108 from balena-io/scan_prod_devices
scan: Print production devices' info on scan
2020-12-01 14:04:58 +00:00
7d5a64f59a scan: Print production devices' info on scan
Resolves: #1713
Change-type: minor
Signed-off-by: Marios Balamatsias <mbalamatsias@gmail.com>
2020-12-01 13:31:29 +02:00
8115d156df v12.28.3 2020-11-26 16:48:38 +02:00
08fc1a3924 Merge pull request #2104 from balena-io/delay-investigation
Add ability to disable analytics for performance testing
2020-11-26 14:46:51 +00:00
950d173d27 Add ability to disable analytics for performance testing
Change-type: patch
Connects-to: #1708
Signed-off-by: Scott Lowe <scott@balena.io>
2020-11-26 13:47:48 +01:00
ac49246141 v12.28.2 2020-11-20 14:44:30 +02:00
0689074dd7 Merge pull request #2097 from balena-io/2096-login-unhandled-rejection
Fix unhandled rejection, --debug logic and add doc references to masterclasses
2020-11-20 12:42:13 +00:00
ee79c87723 v12.28.1 2020-11-20 12:57:08 +02:00
9dc9556619 Merge pull request #2099 from balena-io/2098-scan-json-spinner
scan: Prevent spinner animation output to stdout when --json is used
2020-11-20 10:55:23 +00:00
2f9212d622 scan: Prevent spinner animation output to stdout when --json is used
Change-type: patch
2020-11-20 00:23:26 +00:00
2bf59530c4 docs: Add references to the masterclasses in the CLI help and README
Change-type: patch
2020-11-19 18:13:45 +00:00
a4fd7d6118 Fix debug message logic (don't suggest --debug if it is already being used)
Change-type: patch
2020-11-19 18:13:45 +00:00
65f053dd6e Fix unhandled promise rejection when ~/.balena is not accessible
Resolves: #2096
Change-type: patch
2020-11-19 18:13:45 +00:00
8137b79078 v12.28.0 2020-11-19 20:03:53 +02:00
e9b5773bcb Merge pull request #2093 from balena-io/2091-livepush-use-dockerignore
Livepush: Ignore paths set in .dockerignore files
2020-11-19 18:00:54 +00:00
4768f76385 push: Reduce memory usage when filtering files with dockerignore
Change-type: patch
2020-11-19 14:24:54 +00:00
d6b3249274 Livepush: Refactor dockerignore filtering and add test cases
Change-type: patch
2020-11-19 14:24:54 +00:00
02a5466746 Livepush: Ignore paths set in .dockerignore files
Change-type: minor
Signed-off-by: Josh Bowling <josh@balena.io>
2020-11-19 14:24:44 +00:00
0831e5fa17 v12.27.4 2020-11-16 17:35:40 +02:00
4681d901f8 Merge pull request #2095 from balena-io/big-sur-notarization
Test code optimization: avoid running ~70 test cases twice
2020-11-16 15:32:39 +00:00
6a55613199 Test code optimization: avoid running ~70 test cases twice
Change-type: patch
2020-11-15 23:36:58 +00:00
893a39e891 docs: Add note about macOS Big Sur notarization workaround
Change-type: patch
2020-11-14 22:23:41 +00:00
fa4f91e08d v12.27.3 2020-11-11 19:25:53 +02:00
54dc37dbd3 Merge pull request #2094 from balena-io/expect-invalid-yaml
Avoid reporting balenarc parsing errors
2020-11-11 17:23:44 +00:00
1b0c14feab Avoid reporting balenarc parsing errors
Change-type: patch
Connects-to: #1100
Signed-off-by: Scott Lowe <scott@balena.io>
2020-11-11 17:04:26 +01:00
20e0810d2a v12.27.2 2020-11-09 14:41:47 +02:00
edc2e77ddd Merge pull request #2084 from balena-io/codewithcheese/append-dev
Modify `os download` help to mention `-dev` suffix
2020-11-09 12:39:51 +00:00
7da9a800cc Modify os download help to mention dev images
Change-type: patch
Signed-off-by: Thomas Manning <thomasm@balena.io>
2020-11-09 06:13:06 +00:00
2ba4405452 v12.27.1 2020-11-06 11:36:38 +02:00
e7ebf1ad12 Merge pull request #2081 from balena-io/app-disambiguation
Improve application-identifier disambiguation
2020-11-06 09:34:15 +00:00
46249e319b Improve application-identifier disambiguation
Change-type: patch
Resolves: #2077
Signed-off-by: Scott Lowe <scott@balena.io>
2020-11-06 09:39:36 +01:00
fcd0932df8 v12.27.0 2020-11-05 18:45:17 +02:00
34792ecce9 Merge pull request #2067 from balena-io/app-purge
Add command app purge
2020-11-05 16:43:14 +00:00
1e18096873 Add command app purge
Change-type: minor
Signed-off-by: Scott Lowe <scott@balena.io>
2020-11-05 17:07:06 +01:00
4da1ed3a56 v12.26.2 2020-11-05 16:03:57 +02:00
92b8741288 Merge pull request #2088 from balena-io/2087-resource-uuid
config generate + openBalena v3: Fix "Cannot read property '__id' of undefined"
2020-11-05 14:02:00 +00:00
6b4c28a026 config generate + openBalena v3: Fix "Cannot read property '__id' of undefined"
Change-type: patch
2020-11-05 13:22:15 +00:00
849fc24158 v12.26.1 2020-10-31 03:09:18 +02:00
16efb9748f Merge pull request #2074 from balena-io/devices-slug-undefined
devices: Fix "TypeError: Cannot read property 'slug' of undefined"
2020-10-31 01:07:16 +00:00
9d177609f5 devices: Fix "TypeError: Cannot read property 'slug' of undefined"
Change-type: patch
2020-10-31 00:29:50 +00:00
826b0659d6 v12.26.0 2020-10-29 12:40:24 +02:00
46d7d1d068 Merge pull request #2066 from balena-io/device-purge
Add command device purge
2020-10-29 10:38:32 +00:00
47fcffe368 Add command device purge
Change-type: minor
Resolves: #1547
Signed-off-by: Scott Lowe <scott@balena.io>
2020-10-29 11:03:37 +01:00
bb7cd7ac62 v12.25.6 2020-10-28 12:36:54 +02:00
a83f6c95df Merge pull request #2076 from balena-io/2075-ssh-service-regex
ssh: Fix "Found more than one container with a service name <name>"
2020-10-28 10:35:14 +00:00
7f000ee8c3 ssh: Fix "Found more than one container with a service name <name>"
Change-type: patch
2020-10-28 01:06:54 +00:00
e5e7bb4757 v12.25.5 2020-10-27 13:10:06 +02:00
37e6bd4b5c Merge pull request #2073 from balena-io/rm-hardcoded-command-ids
Remove need for hardcoded list of command ids
2020-10-27 11:08:27 +00:00
c48564e85a Remove need for hardcoded list of command ids
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-10-27 10:36:49 +01:00
8460dac066 v12.25.4 2020-10-26 08:23:55 +02:00
64ffcfdd91 Merge pull request #2072 from balena-io/1893-config-generate-application-ambiguous
config generate: Fix "Application is ambiguous" when app slug is used
2020-10-26 06:22:14 +00:00
077e25ebc4 Update Contributing document re commit messages / versionbot / changelog
Change-type: patch
2020-10-25 17:11:19 +00:00
709f009f9b config generate: Fix "Application is ambiguous" when app slug is used
Connects-to: #1893
Change-type: patch
2020-10-25 16:52:31 +00:00
116ab1fbc1 config generate: Fix device type compatibility check
Change-type: patch
2020-10-25 16:40:11 +00:00
260a30532a v12.25.3 2020-10-23 13:07:34 +03:00
7534042519 Merge pull request #2069 from balena-io/1053-buildArgs-compose
Fix `--buildArg` with compose projects; Convert `buildProject` to Typescript
2020-10-23 10:04:53 +00:00
6b208ec2ab build/deploy: Add more test cases (--buildArg option)
Change-type: patch
2020-10-21 14:25:40 +01:00
099d755900 Fix typing (don't assume that 'docker-toolbelt' uses Bluebird promises)
Change-type: patch
2020-10-21 14:25:40 +01:00
3199f15662 build/deploy: Fix --buildArg option with docker-compose.yml projects
Resolves: #1053
Change-type: patch
2020-10-21 13:02:47 +01:00
4c8dc29946 build/deploy: Fix image size notice at end of build
Change-type: patch
2020-10-21 13:02:47 +01:00
2b22fb89f1 Convert more code to Typescript (compose.js)
Change-type: patch
2020-10-21 13:02:47 +01:00
cf7d9246e5 v12.25.2 2020-10-21 15:02:26 +03:00
0d3106af0e Merge pull request #2070 from balena-io/revert-balenacli-styling
Fix preload --add-certificate, amend help for `version`, and revert `balenaCLI` styling in docs
2020-10-21 12:00:21 +00:00
478b5dd363 Revert styling of "balena CLI" as "balenaCLI"
Change-type: patch
2020-10-21 00:07:46 +01:00
0708608c7e Add help note regarding the version of Node.js printed by balena version -a
Connects-to: #2068
Change-type: patch
2020-10-21 00:07:45 +01:00
c245dc70c2 preload: Fix parsing of --add-certificate option, amend help for --app
Connects-to: #2063
Change-type: patch
2020-10-21 00:07:15 +01:00
4373ba7a5d v12.25.1 2020-10-13 11:58:10 +03:00
2cc8d15c05 Merge pull request #2054 from balena-io/ignore-unauthorized-errors
Treat authorization errors as expected
2020-10-13 08:56:13 +00:00
592efd0a2e Treat authorization errors as expected
Change-type: patch
Resolves: #2035
Signed-off-by: Scott Lowe <scott@balena.io>
2020-10-13 10:16:16 +02:00
31123d28f0 v12.25.0 2020-10-13 11:01:54 +03:00
9b6ffecaba Merge pull request #2061 from balena-io/2060-balenarc-no-sentry
Update Sentry, add BALENARC_NO_SENTRY var, refactor CLI initialization
2020-10-13 07:59:51 +00:00
d0e4fa0e59 Refactor initialization code (delete app-oclif.ts and app-common.ts)
Change-type: patch
2020-10-11 00:45:53 +01:00
cf376316bc Support BALENARC_NO_SENTRY env var to disable Sentry.io error reporting
Change-type: minor
2020-10-10 00:45:55 +01:00
8f0f3bda29 Update Sentry package (may fix "Maximum call stack size exceeded")
Change-type: patch
2020-10-09 13:03:53 +01:00
c33409adb0 v12.24.1 2020-10-07 19:28:54 +03:00
873eb1fc59 Merge pull request #2057 from balena-io/allow-alternative-signups
login: sign up at the configured balena instance
2020-10-07 16:27:08 +00:00
af70f16a9b login: sign up at the configured balena instance
Change-type: patch
Signed-off-by: Matthew McGinn <matthew@balena.io>
2020-10-07 09:20:50 -04:00
e8d757ca28 v12.24.0 2020-10-06 23:10:25 +03:00
63d3402924 Merge pull request #2056 from balena-io/scan-json
scan: Add '--json' option to help with scripting
2020-10-06 20:08:48 +00:00
8a506bc4c0 scan: Add '--json' option to help with scripting
Change-type: minor
2020-10-06 18:04:43 +01:00
a14d89fe10 v12.23.4 2020-10-06 00:48:28 +03:00
29ed0a232d Merge pull request #2053 from balena-io/fix-balenadev
Workaround balena-dev/oclif compatibility issues
2020-10-05 21:46:19 +00:00
8978221866 Update CONTRIBUTING.md re balena-dev workflow
Change-type: patch
2020-10-05 22:10:54 +01:00
2974c203b5 Add bin/balena* scripts to linter paths
Change-type: patch
2020-10-05 22:10:54 +01:00
c85acbd90b Workaround balena-dev/oclif compatibility issues
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-10-02 16:17:16 +02:00
8a808e25d0 v12.23.3 2020-10-02 15:22:36 +03:00
75687f51ac Merge pull request #2052 from balena-io/rename-actions
Rename actions-oclif/ to commands/
2020-10-02 12:20:57 +00:00
eddbdfe0dc Rename actions-oclif/ to commands/
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-10-02 13:42:23 +02:00
d8acc3f814 v12.23.2 2020-10-02 12:35:48 +03:00
fc8be3d8dc Merge pull request #2051 from balena-io/fix-push-emulated-flag
push: Fix accidental rename of `-e` (emulated) option
2020-10-02 09:33:46 +00:00
0ee02a4d73 v12.23.1 2020-10-02 11:50:46 +03:00
568fcb9759 Merge pull request #2049 from balena-io/refresh-contributing
Update the CONTRIBUTING.md document
2020-10-02 08:49:00 +00:00
6133bb2096 push: Fix accidental rename of -e (emulated) option
Accidentally renamed during oclif conversion in CLI v12.9.7.

Change-type: patch
2020-10-01 23:49:14 +00:00
48076464da Update the CONTRIBUTING.md document
Change-type: patch
2020-09-28 15:19:10 +01:00
1acf342fb0 v12.23.0 2020-09-25 18:13:07 +03:00
340ca6577b Merge pull request #2046 from balena-io/add-support
Add new command `support`
2020-09-25 15:11:05 +00:00
0a8b3ce4e4 Add new command support
Change-type: minor
Resolves: #766 #1546
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-25 16:20:45 +02:00
65c01ac172 v12.22.2 2020-09-20 03:20:05 +03:00
4c9a22aba7 Merge pull request #2041 from balena-io/2040-fix-deploy-exit
deploy: Fix unexpected exit with "Everything is up to date"
2020-09-20 00:18:08 +00:00
889fafcffc deploy: Fix unexpected exit with "Everything is up to date"
Resolves: #2040
Change-type: patch
2020-09-20 00:35:57 +01:00
719cc2e4c9 v12.22.1 2020-09-19 12:23:16 +03:00
e484701276 Merge pull request #2039 from balena-io/1760-simplify-install-docs
Reorganize and simplify installation instructions
2020-09-19 09:21:20 +00:00
b1897a512d Style "balena CLI" as "balenaCLI" and "balena cloud" as "balenaCloud"
Change-type: patch
2020-09-18 23:27:24 +01:00
f98c25eaee Reorganize and simplify installation instructions
Change-type: patch
2020-09-18 14:13:32 +01:00
b9c3b57b85 v12.22.0 2020-09-18 15:41:12 +03:00
8aff330516 Merge pull request #2038 from balena-io/add-device-restart
Add new command `device restart`
2020-09-18 12:39:09 +00:00
abdaf0043f Add new command device restart
Change-type: minor
Resolves: #1542
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-18 12:39:53 +02:00
960cb3098d v12.21.1 2020-09-17 03:48:23 +03:00
e907f12445 Merge pull request #2037 from balena-io/2036-scan-sync-standalone
scan: Fix "CLI could not be loaded" with the standalone zip installer
2020-09-17 00:46:19 +00:00
799e0f9dea scan: Fix "CLI could not be loaded" with the standalone zip installer
Change-type: patch
2020-09-17 00:38:00 +01:00
c389f41006 v12.21.0 2020-09-16 18:16:57 +03:00
74ca5207ad Merge pull request #2034 from balena-io/add-app-rename
Add new command `app rename`
2020-09-16 15:14:54 +00:00
3706db2436 Add new command app rename
Change-type: minor
Resolves: #1567
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-16 16:38:10 +02:00
6ec0b4a3bd v12.20.0 2020-09-10 21:28:04 +03:00
e65caed64e Merge pull request #2033 from balena-io/post-capitano-refactor
Improve command suggestions, add topic help
2020-09-10 18:26:29 +00:00
b180eb7b73 Minor fix to device rm
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 17:34:06 +02:00
9805854eab Update registry secrets example URL
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 17:33:57 +02:00
00c956394d Improve command suggestions, add topic help
Change-type: minor
Resolves: #2021
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 15:56:45 +02:00
b3510f205f v12.19.0 2020-09-10 15:09:25 +03:00
e755d9f03f Merge pull request #2032 from balena-io/misc-bugfixes
Misc bugfixes
2020-09-10 12:07:33 +00:00
f9224b05af Fix numerical id support in device rm
Change-type: patch
Resolves: #2031
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 12:53:02 +02:00
ece4d88bfd Fix numerical id support in device move
Change-type: patch
Resolves: #2030
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 12:53:02 +02:00
0dd7c33237 Fix required status of param in os versions
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-10 12:53:02 +02:00
cd20f1765e Merge pull request #2028 from balena-io/join-poll
Add ability to specify poll interval in join command
2020-09-09 13:56:12 +00:00
0ca1faba09 Add ability to specify poll interval in join command
Change-type: minor
Resolves: #1432 #1697 #1670
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-09 14:24:45 +02:00
9f8569e33f Improve error handling in internal scandevices
Change-type: patch
Connects-to: #1703
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-09 14:24:45 +02:00
d7007721a7 v12.18.0 2020-09-09 14:38:49 +03:00
f9f1863fdb Merge pull request #2027 from balena-io/1584-push-pull
push (local device): Add --pull option to force pulling base images again
2020-09-09 11:36:41 +00:00
93e18bea27 push (local device): Add --pull option to force pulling base images again
Connects-to: #1584
Change-type: minor
2020-09-09 00:16:22 +01:00
73f49765ec push: Reformat help output to fit in 80 characters
Connects-to: #1858
Change-type: patch
2020-09-09 00:16:21 +01:00
3a508dc397 v12.17.2 2020-09-08 21:16:44 +03:00
bd5bf0135a Merge pull request #2026 from balena-io/fix-device-issue
Fix error displaying info for devices without commits
2020-09-08 18:14:24 +00:00
e0c65bdef8 Fix error displaying info for devices without commits
Change-type: patch
Resolves: #2024
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-08 18:57:00 +02:00
b9d90b9e38 v12.17.1 2020-09-08 15:16:37 +03:00
d910319ba5 Merge pull request #2025 from balena-io/convert-help-fixes
Fix issues from removal of capitano
2020-09-08 12:14:34 +00:00
5e5a2c1c85 Fix usage info for env rename
Change-type: patch
Resolves: #2019
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-08 13:31:41 +02:00
238c371ade Fix typo in docs help section
Change-type: patch
Resolves: #2020
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-08 13:23:39 +02:00
504877c232 Fix issue with replaced command checks
Change-type: patch
Resolves: #2022
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-08 13:13:53 +02:00
bdcf58471f v12.17.0 2020-09-07 16:31:25 +03:00
46b9c586a6 Merge pull request #2018 from balena-io/convert-help
Convert help, remove capitano
2020-09-07 13:29:24 +00:00
273ea5ce4d Display command suggestions, when command not recognized
Change-type: minor
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-07 14:35:35 +02:00
d56fec6e36 Convert help to oclif, remove capitano
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-07 14:13:32 +02:00
cd81ff005f v12.16.0 2020-09-04 14:09:05 +03:00
dee216eeaa Merge pull request #2015 from balena-io/1584-build-pull
build, deploy: Add --pull option to force pulling base images again
2020-09-04 11:07:00 +00:00
d1539f405a build, deploy: Add --pull option to force pulling base images again
Resolves: #1584
Change-type: minor
2020-09-04 10:28:36 +01:00
d131fb4fa8 v12.15.1 2020-09-04 12:26:57 +03:00
a0380848a0 Merge pull request #2017 from balena-io/modify-undervoltage-field
Modify undervoltage status display in device command
2020-09-04 09:25:00 +00:00
ffa8e245ba Modify undervoltage status display in device command
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-04 10:32:40 +02:00
8631e22686 v12.15.0 2020-09-03 20:31:45 +03:00
f0bd3a38db Merge pull request #2014 from balena-io/new-device-fields
Add support for new device metrics to device command
2020-09-03 17:29:18 +00:00
88569066b5 Add support for new device metrics to device command
Change-type: minor
Signed-off-by: Scott Lowe <scott@balena.io>
2020-09-03 18:26:53 +02:00
c20bbe658b v12.14.18 2020-08-28 17:10:37 +03:00
ac0ce8f702 Merge pull request #2013 from balena-io/async-await-oclif
Convert oclif actions to async/await
2020-08-28 14:08:48 +00:00
42c6e1010f Convert oclif actions to async/await
Change-type: patch
2020-08-28 13:43:10 +01:00
1f4554abe8 v12.14.17 2020-08-27 17:51:29 +03:00
4e457da5a9 Merge pull request #2011 from balena-io/improve-preload-typings
preload: Improve the typings
2020-08-27 14:48:34 +00:00
2e1570149d preload: Improve the typings
Change-type: patch
Depends-on: https://github.com/balena-io/balena-sdk/pull/980
See: https://github.com/balena-io/balena-cli/pull/2007#discussion_r478330624
Signed-off-by: Thodoris Greasidis <thodoris@balena.io>
2020-08-27 16:44:08 +03:00
c647989054 Update balena-sdk to 15.3.7
Update balena-sdk from 15.3.1 to 15.3.7

Change-type: patch
2020-08-27 12:51:13 +00:00
44bd667648 v12.14.16 2020-08-27 15:37:55 +03:00
2d042ee116 Merge pull request #2010 from balena-io/update-typescript
Update to typescript 4.0
2020-08-27 13:35:40 +01:00
787966a0b6 Update to typescript 4.0
Change-type: patch
2020-08-27 11:50:57 +01:00
a59d85e833 v12.14.15 2020-08-27 13:25:58 +03:00
d0616acf1b Merge pull request #2007 from balena-io/convert-preload
Convert command preload to oclif, typescript
2020-08-27 10:23:08 +00:00
d21a18f353 Convert command preload to oclif, typescript
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-08-27 10:19:54 +02:00
7d3dbc2c0b v12.14.14 2020-08-26 17:13:46 +03:00
529b98552c Merge pull request #2004 from balena-io/dont-try-to-parse-uuid-as-integer-in-tunnel
Don't try to parse deviceOrApplication as an integer in the tunnel action
2020-08-26 14:11:55 +00:00
99a478ee39 Fix device UUID parsing for 'balena tunnel'
Change-type: patch
2020-08-26 15:10:00 +02:00
fb879d3020 v12.14.13 2020-08-26 13:40:27 +03:00
4fb4cce842 Merge pull request #2006 from balena-io/1993-preload-logo
preload: Fix splash image file name for balenaOS >= 2.53.0
2020-08-26 10:38:14 +00:00
f772957d29 preload: Fix splash image file name for balenaOS >= 2.53.0
Resolves: #1993
Change-type: patch
2020-08-26 10:48:30 +01:00
fd9520224c v12.14.12 2020-08-24 13:14:10 +03:00
c1afaa6cf3 Merge pull request #2000 from balena-io/convert-deploy
Convert deploy command to oclif
2020-08-24 10:12:03 +00:00
8cb413c1c9 Convert deploy command to oclif
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-08-22 11:58:10 +02:00
e96fca551e v12.14.11 2020-08-21 14:28:43 +03:00
edb3ea53fb Merge pull request #2001 from balena-io/fix-template-patch
Fix help template bug affecting discrete value options
2020-08-21 11:26:26 +00:00
358a909214 Shorten env add command summary to prevent wrapping
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-08-21 12:43:12 +02:00
eb74ca631a Fix help template bug affecting discrete value options
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-08-21 12:41:35 +02:00
64ebebb121 v12.14.10 2020-08-15 01:49:55 +03:00
abc62404ab Merge pull request #1997 from balena-io/1992-fix-build-args
build: Fix --buildArg and --cache-from options (broken since v12.9.9)
2020-08-14 22:47:48 +00:00
af1c4b0d03 build: Fix --buildArg and --cache-from options (broken since v12.9.9)
Change-type: patch
2020-08-14 23:12:12 +01:00
830e1f801d v12.14.9 2020-08-14 16:49:45 +03:00
59c398fbf0 Merge pull request #1995 from balena-io/update-deps
Update dependencies
2020-08-14 13:47:42 +00:00
d7f49d2442 Update dependencies
Update balena-sdk from 15.3.0 to 15.3.1

Change-type: patch
2020-08-14 13:28:06 +01:00
34597f629d v12.14.8 2020-08-14 01:51:05 +03:00
3fa7eec8a9 Merge pull request #1996 from balena-io/fix-tar-stream
build / push: Fix sudden process crash (update tar-utils dependency)
2020-08-13 22:49:17 +00:00
1ee12b70bc build / push: Fix sudden process crash (update tar-utils dependency)
Change-type: patch
2020-08-13 23:05:11 +01:00
ca7b1ae084 v12.14.7 2020-08-13 18:03:37 +03:00
936d3cb62a Merge pull request #1994 from balena-io/update-deps
Update dependencies
2020-08-13 15:01:20 +00:00
230677e5e8 Update dependencies
Update balena-sdk from 15.2.5 to 15.3.0

Change-type: patch
2020-08-13 14:36:13 +01:00
025f817eb6 v12.14.6 2020-08-12 18:29:07 +03:00
54cceb688f Merge pull request #1982 from balena-io/allow-shrinkwrap-rebase
Allow rebasing of npm-shrinkwrap
2020-08-12 15:27:15 +00:00
648a73fd91 Tests: check shrinkwrap is deduped
Change-type: patch
2020-08-12 15:34:53 +01:00
3691ae148e Allow rebasing of npm-shrinkwrap
Change-type: patch
2020-08-12 14:46:51 +01:00
4496bc88f5 v12.14.5 2020-08-11 20:24:07 +03:00
afded27692 Merge pull request #1974 from balena-io/sdk-15
Update balena-sdk to 15.x
2020-08-11 17:21:59 +00:00
c1a5718364 Avoid trying to refresh token in tests 2020-08-11 17:54:39 +01:00
e021ad9af6 Update balena-sdk to 15.x
Update balena-config-json from 4.0.0 to 4.1.0
Update balena-image-manager from 7.0.1 to 7.0.3
Update balena-preload from 10.2.0 to 10.2.4
Update balena-sdk from 14.8.0 to 15.2.1
Update balena-sync from 11.0.0 to 11.0.2

Change-type: patch
2020-08-11 17:53:33 +01:00
5c8a5165e0 v12.14.4 2020-08-11 19:27:44 +03:00
71ff73c641 Merge pull request #1989 from balena-io/improve-tests
Improve tests
2020-08-11 16:25:57 +00:00
c35472e94d Tests: Specify es2018 as preferred, matching normal usage
Change-type: patch
2020-08-11 16:07:23 +01:00
511bb05cb9 Tests: Use a tmp data dir to avoid conflicts/overwriting existing data
Change-type: patch
2020-08-11 16:05:28 +01:00
53b2b54b23 Tests: Use a mocha file helper to always load config-tests first
Change-type: patch
2020-08-11 16:04:28 +01:00
e7f753007f Tests: Share mocha options between commands in package.json
Change-type: patch
2020-08-11 16:03:36 +01:00
0afaf8502f v12.14.3 2020-08-11 16:06:00 +03:00
3272b55dd9 Merge pull request #1988 from balena-io/avoid-unnecessary-tests
CI: Avoid unnecessary duplicate windows/mac tests
2020-08-11 13:04:02 +00:00
4c664167f6 CI: Avoid duplicate windows/mac tests
Change-type: patch
2020-08-11 12:32:36 +01:00
604c182e2c v12.14.2 2020-08-10 16:46:37 +03:00
60593a77ac Merge pull request #1987 from balena-io/lazy-load-tar-stream
Lazy load tar-stream
2020-08-10 13:44:39 +00:00
497c8cd49b Lazy load tar-stream
Change-type: patch
2020-08-10 12:01:55 +01:00
d348d9f71f v12.14.1 2020-08-08 01:23:00 +03:00
d6651fdd7e Merge pull request #1969 from balena-io/update-balena-sdk
Update balena-sdk to 14.x
2020-08-07 22:21:14 +00:00
e1c42405a1 Update balena-sdk to 14.x
Update balena-sdk from 13.6.0 to 14.8.0

Change-type: patch
2020-08-07 21:06:07 +00:00
bf22d9eaa8 v12.14.0 2020-08-07 23:58:35 +03:00
88523a2887 Merge pull request #1986 from balena-io/bump-chokidar-livepush
Live push: Accept Dockerfile ENV instructions in push to local device
2020-08-07 20:56:30 +00:00
e8eb031253 Live push: Accept Dockerfile ENV instructions in live push to local device
Update livepush, chokidar and @types/dockerode dependencies

Change-type: minor
2020-08-07 21:09:47 +01:00
120c82d657 v12.13.0 2020-08-07 18:13:27 +03:00
cb2e60d5af Merge pull request #1985 from balena-io/devices-json
devices: Add '--json' option to help with scripting
2020-08-07 15:11:32 +00:00
62dfae371c devices: Add '--json' option to help with scripting
Change-type: minor
2020-08-07 15:27:42 +01:00
eaf220b64f v12.12.2 2020-08-07 15:26:38 +03:00
9804dd3c33 Merge pull request #1983 from balena-io/pkg-warning-diff
Tests: add verification of 'pkg' warnings against saved output
2020-08-07 12:24:40 +00:00
94f3825119 Tests: add verification of 'pkg' warnings against saved output
Change-type: patch
2020-08-07 01:47:31 +01:00
abde3cf48a v12.12.1 2020-08-05 16:14:57 +03:00
efb488f81a Merge pull request #1977 from balena-io/reduce-bluebird
Reduce bluebird usage
2020-08-05 13:12:59 +00:00
6ca7c34e57 Reduce bluebird usage
Change-type: patch
2020-08-05 09:41:15 +01:00
3f084366db v12.12.0 2020-08-05 03:27:13 +03:00
9f98529e56 Merge pull request #1962 from nwneisen/1956-bulk-add-env-vars
Add ability to add env var to multiple locations in one command
2020-08-05 00:25:32 +00:00
bab98df87b env add: Add ability to add env var to multiple locations in one command
Change-type: minor
2020-08-04 17:40:26 -06:00
4d9affd030 v12.11.3 2020-08-04 22:28:57 +03:00
15b536a3b2 Merge pull request #1980 from balena-io/dont-change-preloader-config
Dont set the preloader config device type to intel-nuc
2020-08-04 19:26:42 +00:00
505acc19db Dont set the preloader config device type to intel-nuc
Change-type: patch
2020-08-04 20:12:28 +02:00
1d566a72ca v12.11.2 2020-08-04 20:23:26 +03:00
0337e284a6 Merge pull request #1978 from balena-io/1975-fix-os-build-config-undefined
os build-config: Fix output file containing 'undefined' word since v12.9.4
2020-08-04 17:21:25 +00:00
74c6f8a627 os build-config: Fix output file containing 'undefined' word since v12.9.4
Resolves: #1975
Change-type: patch
2020-08-04 17:27:20 +01:00
7aa1708f46 v12.11.1 2020-08-04 19:25:57 +03:00
32a21684e8 Merge pull request #1976 from balena-io/balena-release-v3
Update balena-release to v3
2020-08-04 16:23:50 +00:00
a52a623fdf Update balena-release to v3
Change-type: patch
Signed-off-by: Thodoris Greasidis <thodoris@balena.io>
2020-08-04 15:35:29 +00:00
b63e31e255 v12.11.0 2020-08-04 18:34:05 +03:00
fec01977c7 Merge pull request #1972 from balena-io/1773-browser-login-port-number
login: Use any free port number instead of 8989 for web authentication
2020-08-04 15:32:06 +00:00
cf894d98a5 login: Use any free port number instead of 8989 for web authentication
Change-type: minor
2020-08-03 20:26:10 +01:00
d18f25cb9c Testing: Increase mocha timeout from 6s to 12s (experimental)
Change-type: patch
2020-08-03 20:26:10 +01:00
4cdff9694e Update GitHub issue template (request openBalena vs balenaCloud info)
Change-type: patch
2020-08-03 20:26:10 +01:00
304ade9772 v12.10.2 2020-08-03 18:57:08 +03:00
0865633020 Merge pull request #1973 from balena-io/fix-per-regression
Fix performance regressions from #1967
2020-08-03 15:55:16 +00:00
ddb87f403d Fix performance regressions from #1967
Change-type: patch
2020-08-03 11:04:36 +01:00
8047779c0c v12.10.1 2020-07-31 15:00:00 +03:00
9da7f03b2a Merge pull request #1966 from balena-io/reduce-bluebird
Remove some bluebird usage
2020-07-31 11:58:03 +00:00
9aacb7ec56 Remove some bluebird usage
Change-type: patch
2020-07-31 10:33:51 +00:00
41e7ba12ff v12.10.0 2020-07-30 22:22:32 +03:00
10decc785d Merge pull request #1952 from nwneisen/1951-bulk-device-rm
Add ability to remove multiple devices in one command
2020-07-30 19:19:56 +00:00
47e9d39c6f device rm: Add ability to remove multiple devices in one command
Change-type: minor
2020-07-30 10:31:48 -06:00
0eb4e6d770 v12.9.9 2020-07-30 17:31:34 +03:00
492b877d02 Merge pull request #1967 from balena-io/convert-build
Convert command `build` to typescript, oclif, and refactor
2020-07-30 14:27:36 +00:00
09b8cc495c Convert command build to typescript, oclif, and refactor
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-07-30 15:53:55 +02:00
94cc84e5ce v12.9.8 2020-07-30 16:04:09 +03:00
cd3fa4521c Merge pull request #1968 from balena-io/tests-reliability
Tests: improve reliability of os/configure.spec.ts
2020-07-30 13:02:04 +00:00
5d7d687d6c Tests: improve reliability of os/configure.spec.ts
Change-type: patch
2020-07-30 13:24:41 +01:00
5b39878d54 v12.9.7 2020-07-29 16:10:35 +03:00
2c24d80492 Merge pull request #1960 from balena-io/convert-push
Convert command `push` to oclif
2020-07-29 13:08:26 +00:00
0f058a4441 Convert command push to oclif
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-07-29 10:53:17 +00:00
66f793e327 v12.9.6 2020-07-29 13:52:06 +03:00
60917f641b Merge pull request #1964 from balena-io/convert-config
Convert `config` commands to typescript, oclif
2020-07-29 10:50:16 +00:00
56cc70cd50 Convert config commands to typescript, oclif
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-07-29 12:11:18 +02:00
fd4c740d29 v12.9.5 2020-07-28 20:22:34 +03:00
644e643fab Merge pull request #1934 from balena-io/bump-preload-v10
Adjustments for balena-preload v10 (SDK v14). Improved error reporting.
2020-07-28 17:20:34 +00:00
f6529eec26 Adjustments for balena-preload v10 (SDK v14). Improved error reporting.
Change-type: patch
2020-07-28 16:12:11 +00:00
401c116820 v12.9.4 2020-07-28 19:08:34 +03:00
4e37ce14c6 Merge pull request #1953 from balena-io/convert-os-build-config
Convert `os build-config` to oclif, typescript
2020-07-28 16:06:36 +00:00
3fc25cd745 Convert os build-config to oclif, typescript
Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
2020-07-28 15:33:28 +00:00
598423acaa v12.9.3 2020-07-28 18:12:50 +03:00
1685ce16f4 Merge pull request #1954 from balena-io/update-net-keepalive3
Update net-keepalive to v1.3.3, reclaiming ~200MB disk space (npm install)
2020-07-28 15:10:50 +00:00
d4de25e545 Update net-keepalive to v1.3.3, reclaiming ~200MB disk space (npm install)
Change-type: patch
2020-07-28 14:38:26 +00:00
196 changed files with 20461 additions and 13516 deletions

3
.gitattributes vendored
View File

@ -10,6 +10,3 @@ doc/cli.markdown text eol=lf
# crlf for the eol conversion test files
tests/test-data/projects/docker-compose/basic/service2/file2-crlf.sh eol=crlf
tests/test-data/projects/no-docker-compose/basic/src/windows-crlf.sh eol=crlf
# Prevent auto merging of the npm-shrinkwrap.json file: see notes in CONTRIBUTING.md
/npm-shrinkwrap.json merge=binary

View File

@ -11,8 +11,8 @@ community can both contribute and benefit from the answers.*
*Please also check that this issue is not a duplicate. If there is another issue describing
the same problem or feature please add comments to the existing issue.*
*Thank you for your time and effort creating the issue report, and helping us improve the
balena CLI!*
*Thank you for your time and effort creating the issue report, and helping us improve
the balena CLI!*
---
@ -64,6 +64,7 @@ fixed it.
# Specifications
- **balena CLI version:** e.g. 1.2.3 (output of the `"balena version -a"` command)
- **Cloud backend: openBalena or balenaCloud?** If unsure, it will be balenaCloud
- **Operating system version:** e.g. Windows 10, Ubuntu 18.04, macOS 10.14.5
- **32/64 bit OS and processor:** e.g. 32-bit Windows on 64-bit Intel processor
- **Install method:** npm or zip package or executable installer

6
.mocharc-standalone.js Normal file
View File

@ -0,0 +1,6 @@
const commonConfig = require('./.mocharc.js');
module.exports = {
...commonConfig,
spec: ['tests/auth/*.spec.ts', 'tests/commands/**/*.spec.ts'],
};

8
.mocharc.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
spec: 'tests/commands/app/create.spec.ts',
reporter: 'spec',
require: 'ts-node/register/transpile-only',
file: './tests/config-tests',
timeout: 12000,
spec: 'tests/**/*.spec.ts',
};

View File

@ -2,30 +2,19 @@
npm:
platforms:
- name: linux
os: alpine
os: ubuntu
architecture: x86_64
node_versions:
- "10"
- "12"
- "14"
- name: linux
os: alpine
architecture: x86
node_versions:
- "10"
- name: darwin
os: macos
architecture: x86_64
node_versions:
- "10"
- name: windows
os: windows
architecture: x86_64
node_versions:
- "10"
- name: windows
os: windows
architecture: x86
node_versions:
- "10"
- "12"
- "14"
docker:
publish: false

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,12 @@
The balena CLI is an open source project and your contribution is welcome!
* Install the dependencies listed in the [NPM Installation](./INSTALL.md#npm-installation)
section of the `INSTALL.md` file. Check the section [Additional
Dependencies](./INSTALL.md#additional-dependencies) too.
* Clone the `balena-cli` repository, `cd` to it and run `npm install`.
* Install the dependencies listed in the [NPM Installation
section](./INSTALL-ADVANCED.md#npm-installation) section of the installation instructions. Check
the section [Additional Dependencies](./INSTALL-ADVANCED.md#additional-dependencies) too.
* Clone the `balena-cli` repository (or a [forked
repo](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo),
if you are not in the balena team), `cd` to it and run `npm install`.
* Build the CLI with `npm run build` or `npm test`, and execute it with `./bin/balena`
(on a Windows command prompt, you may need to run `node .\bin\balena`).
@ -19,44 +21,89 @@ Before opening a PR, test your changes with `npm test`. Keep compatibility in mi
meant to run on Linux, macOS and Windows. balena CI will run test code on all three platforms, but
this will only help if you add some test cases for your new code!
## ./bin/balena-dev and oclif
## Semantic versioning, commit messages and the ChangeLog
When using `./bin/balena-dev` with oclif-converted commands, it is currently necessary to manually
edit the `oclif` section of `package.json` to replace `./build` with `./lib` as follows:
When a pull request is merged, Balena's versionbot / Continuous Integration system takes care of
automatically creating a new CLI release on both the [npm
registry](https://www.npmjs.com/package/balena-cli) and the GitHub [releases
page](https://github.com/balena-io/balena-cli/releases). The release version numbering adheres to
the [Semantic Versioning's](http://semver.org/) concept of patch, minor and major releases.
Generally, bug fixes and documentation changes are classed as patch changes, while new features are
classed as minor changes. If a change breaks backwards compatibility, it is a major change.
Change from:
```
"oclif": {
"commands": "./build/actions-oclif",
"hooks": {
"prerun": "./build/hooks/prerun/track"
```
A new version entry is also automatically added to the
[CHANGELOG.md](https://github.com/balena-io/balena-cli/blob/master/CHANGELOG.md) file when a pull
request is merged. Each pull request corresponds to a single version / release. Each commit in the
pull request becomes a bullet point entry in the Changelog. The Changelog file should not be
manually edited.
To:
```
"oclif": {
"commands": "./lib/actions-oclif",
"hooks": {
"prerun": "./lib/hooks/prerun/track"
```
To support this automation, a commit message should be structured as follows:
And then remember to change it back before pushing the pull request. This is obviously error prone
and inconvenient, and improvement suggestions are welcome: is there a better solution than
automatically editing `package.json`? It is doable, if it is what needs to be done.
```text
The first line becomes a bullet point in the CHANGELOG file
## Semantic versioning and commit messages
Optionally, a more detailed description in one or more paragraphs.
The detailed description can be seen with `git log`, but it is not copied
to the CHANGELOG file.
The CLI version numbering adheres to [Semantic Versioning](http://semver.org/). The following
header/row is required in the body of a commit message, and will cause the CI build to fail if absent:
```
Change-type: patch|minor|major
```
Version numbers and commit messages are automatically added to the `CHANGELOG.md` file by the CI
build flow, after a pull request is merged. It should not be manually edited.
Only the first line of the commit message is copied to the Changelog file. The `Change-type` footer
must be preceded by a blank line, and indicates the commit's semver change type. When a PR consists
of multiple commits, the commits may have different change type values. As a whole, the PR will
produce a release of the "highest" change type. For example, two commits mixing patch and minor
change types will produce a minor CLI release, while two commits mixing minor and major change
types will produce a major CLI release.
## Editing documentation files (CHANGELOG, README, website...)
The commit message is parsed / checked by versionbot with the
[resin-commit-lint](https://github.com/balena-io-modules/resin-commit-lint#resin-commit-lint)
package.
Because of the way that the Changelog file is automatically updated from commit messages, which
become the source of "what's new" for CLI end users, we advocate "meaningful commits" and
user-focused commit messages. A meaningful commit is one that, in isolation, introduces a fix or
feature (or part of a fix or feature) that makes sense at the Changelog level, and which leaves the
CLI in a non-broken state. Sometimes, in the course of preparing a single pull request, a developer
creates several commits as a way of saving their "work in progress", which may even fail to build
(e.g. `npm run build` fails), and which is then fixed or undone by further commits in the same PR.
In this situation, the recommendation is to "squash" or "fixup" the work-in-progress commits into
fewer, meaningful commits. Interactive rebase is a good tool to achieve this:
[blog](https://thoughtbot.com/blog/git-interactive-rebase-squash-amend-rewriting-history),
[docs](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History).
Mixing multiple distinct features or bug fixes in a single commit is discouraged, because the
description will likely not fit in the single-line Changelog bullet point and also because it
makes it harder to review the pull request (especially a large one) and harder to isolate and
revert individual changes in case a bug is found later on. Create a separate commit for each
feature / bug fix, or even separate pull requests.
If you need to catch up with changes to the master branch while working on a pull request,
use rebase instead of merge: [docs](https://git-scm.com/book/en/v2/Git-Branching-Rebasing).
If `package.json` is updated for dependencies listed in the `repo.yml` file (like `balena-sdk`),
the commit message body should also include a line in the following format:
```
Update balena-sdk from 12.0.0 to 12.1.0
```
This allows versionbot to produce nested Changelog entries (with expandable arrows), pulling in
commit messages from the upstream repositories. The following npm script can be used to
automatically produce a commit with a suitable commit message:
```
npm run update balena-sdk ^12.1.0
```
The script will create a new branch (only if `master` is currently checked out), run `npm update`
with the given target version and commit the `package.json` and `npm-shrinkwrap.json` files. The
script by default will set the `Change-type` to `patch` or `minor`, depending on the semver change
of the updated dependency. A `major` change type can specified as an extra argument:
```
npm run update balena-sdk ^12.14.0 patch
npm run update balena-sdk ^13.0.0 major
```
## Editing documentation files (README, INSTALL, Reference website...)
The `doc/cli.markdown` file is automatically generated by running `npm run build:doc` (which also
runs as part of `npm run build`). That file is then pulled by scripts in the
@ -65,61 +112,56 @@ Documentation page](https://www.balena.io/docs/reference/cli/).
The content sources for the auto generation of `doc/cli.markdown` are:
* Selected sections of the README file.
* The CLI's command documentation in source code (both Capitano and oclif commands), for example:
* `lib/actions/build.coffee`
* `lib/actions-oclif/env/add.ts`
* [Selected
sections](https://github.com/balena-io/balena-cli/blob/v12.23.0/automation/capitanodoc/capitanodoc.ts#L199-L204)
of the README file.
* The CLI's command documentation in source code (`lib/commands/` folder), for example:
* `lib/commands/push.ts`
* `lib/commands/env/add.ts`
The README file is manually edited, but subsections are automatically extracted for inclusion in
`doc/cli.markdown` by the `getCapitanoDoc()` function in
[`automation/capitanodoc/capitanodoc.ts`](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
The `INSTALL.md` and `TROUBLESHOOTING.md` files are also manually edited.
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
## Windows
Please note that `npm run build:installer` (which generates the `.exe` executable installer on
Windows) specifically requires [MSYS2](https://www.msys2.org/) to be installed. Other than that,
the standard Command Prompt or PowerShell can be used (though MSYS2 is still handy, as it provides
'git' and a number of common unix utilities). If you make changes to `package.json` scripts, check
they also run on a standard Windows Command Prompt.
The `npm run build:installer` script (which generates the `.exe` executable installer on Windows)
specifically requires [MSYS2](https://www.msys2.org/) to be installed. Other than that, the
standard Command Prompt or PowerShell can be used (though MSYS2 is still handy, as it provides
'git' and a number of common unix utilities). If changes are made to npm scripts in `package.json`,
check that they also run on a standard Windows Command Prompt.
## Updating the 'npm-shrinkwrap.json' file
The `npm-shrinkwrap.json` file is used to control package dependencies, as documented at
https://docs.npmjs.com/files/shrinkwrap.json.
While developing, the `package.json` file is often modified by, or before, running `npm install`
in order to add, remove or modify dependencies. When `npm install` is executed, it automatically
updates the `npm-shrinkwrap.json` file as well, **taking into account not only the `package.json`
file but also the current state of the `node_modules` folder in your computer.**
Changes to `npm-shrinkwrap.json` can be automatically merged by git during operations like
`rebase`, `pull` and `cherry-pick`, but in some cases this results in suboptimal dependency
resolution (the `node_modules` folder may end up larger than necessary, with consequences to CLI
load time too). For this reason, the recommended way to update `npm-shrinkwrap.json` is to run
`npm install`, possibly alongside `npm dedupe` as well. The following commands can be used to
fix shrinkwrap issues and optimize the dependencies:
Meanwhile, as a text (JSON) file, `git` is capable of merging the `npm-shrinkwrap.json` file during
operations like `rebase`, `cherry-pick` and `pull`. But git's automated merge is not the
recommended way of updating the `npm-shrinkwrap.json` file, because it does not take into account
duplicates or conflicts in the dependency tree, or indeed the state of the `package.json` file
(which may have just been merged). In extreme cases, the automated merge may actually result in a
broken installation. For these reasons, automatic merging of the `npm-shrinkwrap.json` was disabled
through the `.gitattributes` file (the "binary merge driver" allows diff'ing but prevents automatic
merging). Operations like `git rebase` may then result in an error like:
```text
$ git rebase master
warning: Cannot merge binary files: npm-shrinkwrap.json (HEAD vs. c34942b9... test)
Auto-merging npm-shrinkwrap.json
CONFLICT (content): Merge conflict in npm-shrinkwrap.json
error: Failed to merge in the changes.
```sh
git checkout master -- npm-shrinkwrap.json
rm -rf node_modules
npm install # update npm-shrinkwrap.json to satisfy changes to package.json
npm dedupe # deduplicate dependencies from npm-shrinkwrap.json
npm install # re-add optional dependencies removed by dedupe
git add npm-shrinkwrap.json # add it for committing (solve merge errors)
```
Whether or not there is a merge error, the following commands are the recommended way of updating
and committing the `npm-shrinkwrap.json` file:
Note that `npm dedupe` should always be followed by `npm install`, as shown above, even if
`npm install` had already been executed before `npm dedupe`.
```bash
$ rm -rf node_modules # Linux / Mac
$ rmdir /s node_modules # Windows Command Prompt
$ npm checkout master -- npm-shrinkwrap.json # revert it to the master branch state
$ npm install # "cleanly" update the npm-shrinkwrap.json file
$ git add npm-shrinkwrap.json # add it for committing (solve merge errors)
Optionally, these steps may be automated by installing the
[npm-merge-driver](https://www.npmjs.com/package/npm-merge-driver):
```sh
npx npm-merge-driver install -g
```
## TypeScript and oclif
@ -128,18 +170,12 @@ The CLI currently contains a mix of plain JavaScript and
[TypeScript](https://www.typescriptlang.org/) code. The goal is to have all code written in
Typescript, in order to take advantage of static typing and formal programming interfaces.
The migration towards Typescript is taking place gradually, as part of maintenance work or
the implementation of new features. Historically, the CLI was originally written in
[CoffeeScript](https://coffeescript.org), but all CoffeeScript code was migrated to either
Javascript or Typescript.
the implementation of new features.
Similarly, [Capitano](https://github.com/balena-io/capitano) was originally adopted as the CLI's
framework, but later we decided to take advantage of [oclif](https://oclif.io/)'s features such
as native installers for Windows, macOS and Linux, and support for custom flag parsing (for
example, we're still battling with Capitano's behavior of dropping leading zeros of arguments that
look like integers, such as some abbreviated UUIDs). Again, the migration is taking place
gradually, with some CLI commands parsed by oclif and others by Capitano. A simple command line
pre-parsing takes place in `preparser.ts`, to decide whether to route full parsing to Capitano or
to oclif.
Of historical interest, the CLI was originally written in [CoffeeScript](https://coffeescript.org)
and used the [Capitano](https://github.com/balena-io/capitano) framework. All CoffeeScript code was
migrated to either Javascript or Typescript, and Capitano was replaced with oclif. A few file or
variable names still refer to this legacy, for example `automation/capitanodoc/capitanodoc.ts`.
## Programming style
@ -147,29 +183,6 @@ to oclif.
reformats the code. Beyond that, we have a preference for Javascript promises over callbacks, and for
`async/await` over `.then()`.
## Updating upstream dependencies
In order to get proper nested changelogs, when updating upstream modules that are in the repo.yml
(like the balena-sdk), the commit body has to contain a line with the following format:
```
Update balena-sdk from 12.0.0 to 12.1.0
```
Since this is error prone, it's suggested to use the following npm script:
```
npm run update balena-sdk ^12.1.0
```
This will create a new branch (only if you are currently on master), run `npm update` with the
version you provided as a target and commit the package.json & npm-shrinkwrap.json. The script by
default will set the `Change-type` to `patch` or `minor`, depending on the semver change of the
updated dependency, but if you need to use a different one (eg `major`) you can specify it as an
extra argument:
```
npm run update balena-sdk ^12.14.0 patch
npm run update balena-sdk ^13.0.0 major
```
## Common gotchas
One thing that most CLI bugs have in common is the absence of test cases exercising the broken

150
INSTALL-ADVANCED.md Normal file
View File

@ -0,0 +1,150 @@
# balena CLI Advanced Installation Options
**These are alternative, advanced installation options. Most users would prefer the [recommended,
streamlined installation
instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).**
There are 3 options to choose from to install balena's CLI:
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
traditional graphical desktop application installers.
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
Recommended also for scripted installation in CI (continuous integration) environments.
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
in integrating the balena CLI in their existing projects or workflow.
Some specific CLI commands have a few extra installation steps: see section [Additional
Dependencies](#additional-dependencies).
## Executable Installer
This is the recommended installation option on macOS and Windows. Follow the specific OS
instructions:
* [Windows](./INSTALL-WINDOWS.md)
* [macOS](./INSTALL-MAC.md)
> Note regarding WSL ([Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
> If you would like to use WSL, follow the [installations instructions for
> Linux](./INSTALL-LINUX.md) rather than Windows, as WSL consists of a Linux environment.
If you had previously installed the CLI using a standalone zip package, it may be a good idea to
check your system's `PATH` environment variable for duplicate entries, as the terminal will use the
entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package) instructions
for how to modify the PATH variable.
By default, the CLI is installed to the following folders:
OS | Folders
--- | ---
Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone Zip Package
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with the word "standalone", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`_also for the Windows Subsystem for Linux_
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
See instructions for:
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
[Windows](https://www.computerhope.com/issues/ch000549.htm)
> * If you are using macOS 10.15 or later (Catalina, Big Sur), [check this known issue and
> workaround](https://github.com/balena-io/balena-cli/issues/1479).
> * **Linux Alpine** and **Busybox:** the standalone zip package is not currently compatible with
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
> For these, consider the [NPM Installation](#npm-installation) option.
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
> folders and files also present in the `balena-cli` folder.
To update the CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## NPM Installation
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
The npm installation involves building native (platform-specific) binary modules, which require
some additional development tools to be installed first:
* [Node.js](https://nodejs.org/) version 10 (min **10.20.0**) or 12 (version 14 is not yet fully supported)
* **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
When the "system" or "default" Node.js and npm packages are installed with "apt-get" in Linux
distributions like Ubuntu, users often report permission or compilation errors when running
"npm install". This [sample
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
installation steps on an Ubuntu 18.04 base image.
* [Python 2.7](https://www.python.org/), [git](https://git-scm.com/), [make](https://www.gnu.org/software/make/), [g++](https://gcc.gnu.org/)
* **Linux** and **Windows Subsystem for Linux (WSL):**
`sudo apt-get install -y python git make g++`
* **macOS:** install Apple's Command Line Tools by running on a Terminal window:
`xcode-select --install`
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
* Node.js from the [Nodejs.org download page](https://nodejs.org/en/download/).
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++`, `ssh`, `rsync`
and more:
* `pacman -S git openssh rsync gcc make`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based
interactive CLI menus to misbehave. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not**
necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides:
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-wdk-for-windows-10-version-1903)
* [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
provides Python 2.7 and more), by running the following command on an [administrator
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
`npm install -g --production windows-build-tools`
With these dependencies in place, the balena CLI installation command is:
```sh
$ npm install balena-cli -g --production --unsafe-perm
```
`--unsafe-perm` is required when `npm install` is executed as the root user, or on systems where
the global install directory is not user-writable. It allows npm install steps to download and save
prebuilt native binaries, and also allows the execution of npm scripts like `postinstall` that are
used to patch dependencies. It is usually possible to omit `--unsafe-perm` if installing under a
regular (non-root) user account, especially if using a user-managed node installation such as
[nvm](https://github.com/creationix/nvm).
## Additional Dependencies
The `balena ssh`, `scan`, `build`, `deploy`, `preload` and `os configure` commands may require
additional software to be installed. Check the Additional Dependencies sections for each operating
system:
* [Windows](./INSTALL-WINDOWS.md#additional-dependencies)
* [macOS](./INSTALL-MAC.md#additional-dependencies)
* [Linux](./INSTALL-LINUX.md#additional-dependencies)
The `build` and `deploy` commands are also capable of using Docker or balenaEngine on a remote
server, or on a balenaOS device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)). Reasons why this
may be desirable include:
* To avoid having to install Docker on the development machine / laptop.
* To take advantage of a more powerful server (CPU, memory).
* To build or run images "natively" on an ARM device, avoiding the need for QEMU emulation.
To use a remote Docker Engine (daemon) or balenaEngine, specify the remote machine's IP address and
port number with the `--dockerHost` and `--dockerPort` command-line options. For more details,
check `balena help build` or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).

66
INSTALL-LINUX.md Normal file
View File

@ -0,0 +1,66 @@
# balena CLI Installation Instructions for Linux
These instructions are for the recommended installation option. They are suitable for most Linux
distributions, except notably for **Linux Alpine** or **Busybox**. For these distros, see [advanced
installation options](./INSTALL-ADVANCED.md).
Selected operating system: **Linux**
1. Download the latest zip file from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest). Look for a file name that ends
with "-standalone.zip", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add the `balena-cli` folder to the system's `PATH` environment variable. There are several
ways of achieving this on Linux: See this [StackOverflow post](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix). Close and reopen the terminal window
so that the changes to PATH can take effect.
4. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy` and `preload` commands may require additional software to be installed, as described
below.
To update the balena CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## Additional Dependencies
### build, deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh
The `balena ssh` command requires the `ssh` command-line tool to be available. Most Linux
distributions will already have it installed. Otherwise, `sudo apt-get install openssh-client`
should do the trick on Debian or Ubuntu.
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena scan
The `balena scan` command requires a multicast DNS (mDNS) service like
[Avahi](https://en.wikipedia.org/wiki/Avahi_(software)), which is installed by default on most
desktop Linux distributions. Otherwise, on Debian or Ubuntu, the installation command would be
`sudo apt-get install avahi-daemon`.
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used).

68
INSTALL-MAC.md Normal file
View File

@ -0,0 +1,68 @@
# balena CLI Installation Instructions for macOS
These instructions are for the recommended installation option. Advanced users may also be
interested in [advanced installation options](./INSTALL-ADVANCED.md).
Selected operating system: **macOS**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.pkg":
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
2. Double click the downloaded file to run the installer. After the installation completes,
close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `build`, `deploy`
and `preload` commands may require additional software to be installed, as described below.
## Additional Dependencies
### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh
The `balena ssh` command requires the `ssh` command-line tool to be available. To check whether
it is already installed, run `ssh` on a Terminal window. If it is not yet installed, the options
include:
* Download the Xcode Command Line Tools from https://developer.apple.com/downloads
* Or, if you have Xcode installed, open Xcode, choose Preferences → General → Downloads →
Components → Command Line Tools → Install.
* Or, install [Homebrew](https://brew.sh/), then `brew install openssh`
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
present workaround is to either:
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
Long term, we are working on replacing AUFS with overlay2 for the affected device types.

82
INSTALL-WINDOWS.md Normal file
View File

@ -0,0 +1,82 @@
# balena CLI Installation Instructions for Windows
These instructions are for the recommended installation option. Advanced users may also be
interested in [advanced installation options](./INSTALL-ADVANCED.md).
Selected operating system: **Windows**
1. Download the installer from the [latest release
page](https://github.com/balena-io/balena-cli/releases/latest).
Look for a file name that ends with "-installer.exe":
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
2. Double click the downloaded file to run the installer. After the installation completes,
close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows (so that the changes made by the installer to the PATH environment variable can take
effect).
3. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the CLI's version
* `balena help` - should print a list of available commands
No further steps are required to run most CLI commands. The `balena ssh`, `scan`, `build`,
`deploy`, `preload` and `os configure` commands may require additional software to be installed, as
described below.
## Additional Dependencies
### build and deploy
These commands require [Docker](https://docs.docker.com/install/overview/) or
[balenaEngine](https://www.balena.io/engine/) to be available (on a local or remote machine). Most
users will simply follow [Docker's installation
instructions](https://docs.docker.com/install/overview/) to install Docker on the same laptop (dev
machine) where the balena CLI is installed. The [advanced installation
options](./INSTALL-ADVANCED.md) document describes other possibilities.
### balena ssh
The `balena ssh` command requires the `ssh` command-line tool to be available. Microsoft started
distributing an SSH client with Windows 10, which is automatically installed through Windows
Update. To check whether it is installed, run `ssh` on a Windows Command Prompt or PowerShell. It
can also be [manually
installed](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)
if needed. For older versions of Windows, there are several ssh/OpenSSH clients provided by 3rd
parties.
The `balena ssh` command also requires an SSH key to be added to your balena account: see [SSH
Access documentation](https://www.balena.io/docs/learn/manage/ssh-access/). The `balena key*`
command set can also be used to list and manage SSH keys: see `balena help -v`.
### balena scan
The `balena scan` command requires a multicast DNS (mDNS) service like Apple's Bonjour.
Many Windows machines will already have this service installed, as it is bundled in popular
applications such as Skype (Wikipedia lists [several others](https://en.wikipedia.org/wiki/Bonjour_(software))).
Otherwise, Bonjour for Windows can be downloaded and installed from: https://support.apple.com/kb/DL999
### balena preload
Like the `build` and `deploy` commands, the `preload` command requires Docker, with the additional
restriction that Docker must be installed on the local machine (because Docker's bind mounting
feature is used). Also, for some device types (such as the Raspberry Pi), the `preload` command
requires Docker to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Unfortunately, Docker Desktop
for Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1. The
present workaround is to either:
* Downgrade Docker Desktop to version 18.06.1. Link: [Docker CE for
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
* Install the balena CLI on a Linux machine (as Docker for Linux still supports AUFS). A Linux
Virtual Machine also works, but a Docker container is _not_ recommended.
Long term, we are working on replacing AUFS with overlay2 for the affected device types.
### balena os configure
* The `balena os configure` command is currently not supported on Windows natively, but works with
the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL). When
using WSL, [install the balena CLI for
Linux](https://github.com/balena-io/balena-cli/blob/master/INSTALL-LINUX.md).

View File

@ -1,231 +1,12 @@
# balena CLI Installation Instructions
There are 3 options to choose from to install balena's CLI:
Please select your operating system:
* [Executable Installer](#executable-installer): the easiest method on Windows and macOS, using the
traditional graphical desktop application installers.
* [Standalone Zip Package](#standalone-zip-package): these are plain zip files with the balena CLI
executable in them: extract and run. Available for all platforms: Linux, Windows, macOS.
Recommended also for scripted installation in CI (continuous integration) environments.
* [NPM Installation](#npm-installation): recommended for Node.js developers who may be interested
in integrating the balena CLI in their existing projects or workflow.
* [Windows](./INSTALL-WINDOWS.md)
* [macOS](./INSTALL-MAC.md)
* [Linux](./INSTALL-LINUX.md)
Some specific CLI commands have a few extra installation steps: see section [Additional
Dependencies](#additional-dependencies).
> **Windows users:**
> * There is a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg) for installing
> and getting started with the balena CLI on Windows. (The video uses the standalone zip package
> option.)
> * If you are using Microsoft's [Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL), install a balena CLI release
> for Linux rather than for Windows, like the standalone zip package for Linux. An installation
> with the graphical executable installer for Windows will **not** work with WSL.
## Executable Installer
Recommended for Windows (but not Windows Subsystem for Linux) and macOS:
1. Download the latest installer from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with "-installer", for example:
`balena-cli-vX.Y.Z-windows-x64-installer.exe`
`balena-cli-vX.Y.Z-macOS-x64-installer.pkg`
2. Double click the downloaded file to run the installer.
_If you are using macOS Catalina (10.15), [check this known issue and
workaround](https://github.com/balena-io/balena-cli/issues/1479)._
3. After the installation completes, close and re-open any open [command
terminal](https://www.balena.io/docs/reference/cli/#choosing-a-shell-command-promptterminal)
windows so that the changes made by the installer to the PATH environment variable can take
effect. Check that the installation was successful by running the following commands on a
command terminal:
* `balena version` - should print the installed CLI version
* `balena help` - should print the balena CLI help
> Note: If you had previously installed the CLI using a standalone zip package, it may be a good
> idea to check your system's `PATH` environment variable for duplicate entries, as the terminal
> will use the entry that comes first. Check the [Standalone Zip Package](#standalone-zip-package)
> instructions for how to modify the PATH variable.
By default, the CLI is installed to the following folders:
OS | Folders
--- | ---
Windows: | `C:\Program Files\balena-cli\`
macOS: | `/usr/local/lib/balena-cli/` <br> `/usr/local/bin/balena`
## Standalone Zip Package
1. Download the latest zip file from the [releases page](https://github.com/balena-io/balena-cli/releases).
Look for a file name that ends with the word "standalone", for example:
`balena-cli-vX.Y.Z-linux-x64-standalone.zip`_also for the Windows Subsystem for Linux_
`balena-cli-vX.Y.Z-macOS-x64-standalone.zip`
`balena-cli-vX.Y.Z-windows-x64-standalone.zip`
2. Extract the zip file contents to any folder you choose. The extracted contents will include a
`balena-cli` folder.
3. Add the `balena-cli` folder to the system's `PATH` environment variable.
See instructions for:
[Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) |
[macOS](https://www.architectryan.com/2012/10/02/add-to-the-path-on-mac-os-x-mountain-lion/#.Uydjga1dXDg) |
[Windows](https://www.computerhope.com/issues/ch000549.htm)
> * If you are using macOS Catalina (10.15), [check this known issue and
> workaround](https://github.com/balena-io/balena-cli/issues/1479).
> * **Linux Alpine** and **Busybox:** the standalone zip package is not currently compatible with
> these "compact" Linux distributions, because of the alternative C libraries they ship with.
> It should however work with all "desktop" or "server" distributions, e.g. Ubuntu, Debian, Suse,
> Fedora, Arch Linux and many more.
> * Note that moving the `balena` executable out of the extracted `balena-cli` folder on its own
> (e.g. moving it to `/usr/local/bin/balena`) will **not** work, as it depends on the other
> folders and files also present in the `balena-cli` folder.
To update the CLI to a new version, download a new release zip file and replace the previous
installation folder. To uninstall, simply delete the folder and edit the PATH environment variable
as described above.
## NPM Installation
If you are a Node.js developer, you may wish to install the balena CLI via [npm](https://www.npmjs.com).
The npm installation involves building native (platform-specific) binary modules, which require
some additional development tools to be installed first:
* [Node.js](https://nodejs.org/) version 10 (min **10.20.0**) or 12 (version 14 is not yet fully supported)
* **Linux, macOS** and **Windows Subsystem for Linux (WSL):**
Installing Node via [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) is recommended.
When the "system" or "default" Node.js and npm packages are installed with "apt-get" in Linux
distributions like Ubuntu, users often report permission or compilation errors when running
"npm install". This [sample
Dockerfile](https://gist.github.com/pdcastro/5d4d96652181e7da685a32caf629dd44) shows the CLI
installation steps on an Ubuntu 18.04 base image.
* [Python 2.7](https://www.python.org/), [git](https://git-scm.com/), [make](https://www.gnu.org/software/make/), [g++](https://gcc.gnu.org/)
* **Linux** and **Windows Subsystem for Linux (WSL):**
`sudo apt-get install -y python git make g++`
* **macOS:** install Apple's Command Line Tools by running on a Terminal window:
`xcode-select --install`
On **Windows (not WSL),** the dependencies above and additional ones can be met by installing:
* Node.js from the [Nodejs.org download page](https://nodejs.org/en/download/).
* The [MSYS2 shell](https://www.msys2.org/), which provides `git`, `make`, `g++`, `ssh`, `rsync`
and more:
* `pacman -S git openssh rsync gcc make`
* [Set a Windows environment variable](https://www.onmsft.com/how-to/how-to-set-an-environment-variable-in-windows-10): `MSYS2_PATH_TYPE=inherit`
* Note that a bug in the MSYS2 launch script (`msys2_shell.cmd`) makes text-based
interactive CLI menus to misbehave. [Check this Github issue for a
workaround](https://github.com/msys2/MINGW-packages/issues/1633#issuecomment-240583890).
* The Windows Driver Kit (WDK), which is needed to compile some native Node modules. It is **not**
necessary to install Visual Studio, only the WDK, which is "step 2" in the following guides:
* [WDK for Windows 10](https://docs.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk#download-icon-step-2-install-wdk-for-windows-10-version-1903)
* [WDK for earlier versions of Windows](https://docs.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
* The [windows-build-tools](https://www.npmjs.com/package/windows-build-tools) npm package (which
provides Python 2.7 and more), by running the following command on an [administrator
console](https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/):
`npm install -g --production windows-build-tools`
With these dependencies in place, the balena CLI installation command is:
```sh
$ npm install balena-cli -g --production --unsafe-perm
```
`--unsafe-perm` is required when `npm install` is executed as the root user, or on systems where
the global install directory is not user-writable. It allows npm install steps to download and save
prebuilt native binaries, and also allows the execution of npm scripts like `postinstall` that are
used to patch dependencies. It is usually possible to omit `--unsafe-perm` if installing under a
regular (non-root) user account, especially if using a user-managed node installation such as
[nvm](https://github.com/creationix/nvm).
## Additional Dependencies
* The `balena ssh` command requires a recent version of the `ssh` command-line tool to be available:
* macOS and Linux usually already have it installed. Otherwise, search for the available packages
on your specific Linux distribution, or for the Mac consider the [Xcode command-line
tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/).
* Microsoft started distributing an SSH client with Windows 10, which we understand is
automatically installed through Windows Update, but can be manually installed too
([more information](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse)).
For other versions of Windows, there are several ssh/OpenSSH clients provided by 3rd parties.
* The [`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) is needed
for the `balena ssh` command to work behind a proxy. It is available for Linux distributions
like Ubuntu/Debian (`apt install proxytunnel`), and for macOS through
[Homebrew](https://brew.sh/). Windows support is limited to the Windows Subsystem for Linux
(e.g., by installing Ubuntu through the Microsoft App Store). Check the
[README](https://github.com/balena-io/balena-cli/blob/master/README.md) file for proxy
configuration instructions.
* The `balena preload`, `balena build` and `balena deploy --build` commands require
[Docker](https://docs.docker.com/install/overview/) or [balenaEngine](https://www.balena.io/engine/)
to be available:
* The `balena preload` command requires the Docker Engine to support the [AUFS storage
driver](https://docs.docker.com/storage/storagedriver/aufs-driver/). Docker Desktop for Mac and
Windows dropped support for the AUFS filesystem in Docker CE versions greater than 18.06.1, so
the workaround is to downgrade to version 18.06.1 (links: [Docker CE for
Windows](https://docs.docker.com/docker-for-windows/release-notes/#docker-community-edition-18061-ce-win73-2018-08-29)
and [Docker CE for
Mac](https://docs.docker.com/docker-for-mac/release-notes/#docker-community-edition-18061-ce-mac73-2018-08-29)).
See more details in [CLI issue 1099](https://github.com/balena-io/balena-cli/issues/1099).
* Commonly, Docker is installed on the same machine where the CLI is being used, but the
`balena build` and `balena deploy` commands can also use a remote Docker Engine (daemon)
or balenaEngine (which could be a remote device running a [balenaOS development
image](https://www.balena.io/docs/reference/OS/overview/2.x/#dev-vs-prod-images)) by specifying
its IP address and port number as command-line options. Check the documentation for each
command, e.g. `balena help build`, or the [online
reference](https://www.balena.io/docs/reference/cli/#cli-command-reference).
* If you are using Microsoft's [Windows Subsystem for
Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL) and Docker Desktop for
Windows, check the [FAQ item "Docker seems to be
unavailable"](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md#docker-seems-to-be-unavailable-error-when-using-windows-subsystem-for-linux-wsl).
* The `balena scan` command requires a multicast DNS (mDNS) service like Bonjour or Avahi:
* On Windows, check if 'Bonjour' is installed (Control Panel > Programs and Features).
If not, you can download Bonjour for Windows from https://support.apple.com/kb/DL999
* Most 'desktop' Linux distributions ship with [Avahi](https://en.wikipedia.org/wiki/Avahi_(software)).
Search for the installation command for your distribution. E.g. for Ubuntu:
`sudo apt-get install avahi-daemon`
* macOS comes with [Bonjour](https://en.wikipedia.org/wiki/Bonjour_(software)) built-in.
* The `balena os configure` command is currently not supported on Windows natively. Windows users are advised
to install the [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (WSL)
with Ubuntu, and use the Linux release of the balena CLI.
## Configuring SSH keys
The `balena ssh` command requires an SSH key to be added to your balena account. If you had
already added a SSH key in order to [deploy with 'git push'](https://www.balena.io/docs/learn/getting-started/raspberrypi3/nodejs/#adding-an-ssh-key),
then you are probably done and may skip this section. You can check whether you already have
an SSH key in your balena account with the `balena keys` command, or by visiting the
[balena web dashboard](https://dashboard.balena-cloud.com/), clicking on your name -> Preferences
-> SSH Keys.
> Note: An "SSH key" actually consists of a public/private key pair. A typical name for the private
> key file is "id_rsa", and a typical name for the public key file is "id_rsa.pub". Both key files
> are saved to your computer (with the private key optionally protected by a password), but only
> the public key is saved to your balena account. This means that if you change computers or
> otherwise lose the private key, _you cannot recover the private key through your balena account._
> You can however add new keys, and delete the old ones.
If you don't have an SSH key in your balena account:
* If you have an existing SSH key in your computer that you would like to use, you can add it
to your balena account through the balena web dashboard (Preferences -> SSH Keys), or through
the CLI itself:
```bash
# Windows 10 (cmd.exe prompt) example:
$ balena key add MyKey %userprofile%\.ssh\id_rsa.pub
# Linux / macOS example:
$ balena key add MyKey ~/.ssh/id_rsa.pub
```
* To generate a new key, you can follow [GitHub's documentation](https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent),
skipping the step about adding the key to your GitHub account, and instead adding the key to
your balena account as described above.
> Note regarding WSL ([Windows Subsystem for
> Linux](https://docs.microsoft.com/en-us/windows/wsl/about))
> If you would like to use WSL, follow the installations instructions for Linux
> rather than Windows, as WSL consists of a Linux environment.

View File

@ -1,31 +1,30 @@
# balena CLI
The official balena CLI tool.
The official balena Command Line Interface.
[![npm version](https://badge.fury.io/js/balena-cli.svg)](http://badge.fury.io/js/balena-cli)
[![dependencies](https://david-dm.org/balena-io/balena-cli.svg)](https://david-dm.org/balena-io/balena-cli)
## About
The balena CLI (Command-Line Interface) allows you to interact with the balenaCloud and the
[balena API](https://www.balena.io/docs/reference/api/overview/) through a terminal window
on Linux, macOS or Windows. You can also write shell scripts around it, or import its Node.js
modules to use it programmatically.
As an [open-source project on GitHub](https://github.com/balena-io/balena-cli/), your contribution
is also welcome!
The balena CLI is a Command Line Interface for [balenaCloud](https://www.balena.io/cloud/) or
[openBalena](https://www.balena.io/open/). It is a software tool available for Windows, macOS and
Linux, used through a command prompt / terminal window. It can be used interactively or invoked in
scripts. The balena CLI builds on the [balena API](https://www.balena.io/docs/reference/api/overview/)
and the [balena SDK](https://www.balena.io/docs/reference/sdk/node-sdk/), and can also be directly
imported in Node.js applications. The balena CLI is an [open-source project on
GitHub](https://github.com/balena-io/balena-cli/), and your contribution is also welcome!
## Installation
Check the [balena CLI installation instructions on GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
Check the [balena CLI installation instructions on
GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
## Getting Started
### Choosing a shell (command prompt/terminal)
## Choosing a shell (command prompt/terminal)
On **Windows,** the standard Command Prompt (`cmd.exe`) and
[PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell?view=powershell-6)
are supported. We are aware of users also having a good experience with alternative shells,
including:
are supported. Alternative shells include:
* [MSYS2](https://www.msys2.org/):
* Install additional packages with the command:
@ -43,17 +42,17 @@ including:
[comment](https://github.com/balena-io/balena-cli/issues/598#issuecomment-556513098).
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
(WSL). In this case, a Linux distribution like Ubuntu is installed via the Microsoft Store, and a
balena CLI release **for Linux** is recommended. See
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using balena
CLI with WSL and Docker Desktop for Windows.
balena CLI release **for Linux** should be selected. See
[FAQ](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md) for using the
balena CLI with WSL and Docker Desktop for Windows.
On **macOS** and **Linux,** the standard terminal window is supported. _Optionally,_ `bash` command
On **macOS** and **Linux,** the standard terminal window is supported. Optionally, `bash` command
auto completion may be enabled by copying the
[balena-completion.bash](https://github.com/balena-io/balena-cli/blob/master/balena-completion.bash)
file to your system's `bash_completion` directory: check [Docker's command completion
guide](https://docs.docker.com/compose/completion/) for system setup instructions.
### Logging in
## Logging in
Several CLI commands require access to your balenaCloud account, for example in order to push a
new release to your application. Those commands require creating a CLI login session by running:
@ -62,7 +61,7 @@ new release to your application. Those commands require creating a CLI login ses
$ balena login
```
### Proxy support
## Proxy support
HTTP(S) proxies can be configured through any of the following methods, in precedence order
(from higher to lower):
@ -88,19 +87,26 @@ HTTP(S) proxies can be configured through any of the following methods, in prece
* The `HTTPS_PROXY` and/or `HTTP_PROXY` environment variables, in the same URL format as
`BALENARC_PROXY`.
> Note: The `balena ssh` command has additional setup requirements to work behind a proxy.
> Check the [installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md),
> and ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
> SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
> server, it should be configured with the following rules in the `squid.conf` file:
> `acl SSL_ports port 22`
> `acl Safe_ports port 22`
### Proxy setup for balena ssh
#### Proxy exclusion
In order to work behind a proxy server, the `balena ssh` command requires the
[`proxytunnel`](http://proxytunnel.sourceforge.net/) package (command-line tool) to be installed.
`proxytunnel` is available for Linux distributions like Ubuntu/Debian (`apt install proxytunnel`),
and for macOS through [Homebrew](https://brew.sh/). Windows support is limited to the [Windows
Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about) (e.g., by installing
Ubuntu through the Microsoft App Store).
Ensure that the proxy server is configured to allow proxy requests to ssh port 22, using
SSL encryption. For example, in the case of the [Squid](http://www.squid-cache.org/) proxy
server, it should be configured with the following rules in the `squid.conf` file:
`acl SSL_ports port 22`
`acl Safe_ports port 22`
### Proxy exclusion
The `BALENARC_NO_PROXY` variable may be used to exclude specified destinations from proxying.
> * This feature requires balena CLI version 11.30.8 or later. In the case of the npm [installation
> * This feature requires CLI version 11.30.8 or later. In the case of the npm [installation
> option](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md), it also requires
> Node.js version 10.16.0 or later.
> * To exclude a `balena ssh` target from proxying (IP address or `.local` hostname), the
@ -129,25 +135,27 @@ address like `192.168.1.2`.
## Command reference documentation
The full CLI command reference is available [on the web](https://www.balena.io/docs/reference/cli/
) or by running `balena help` and `balena help --verbose`.
) or by running `balena help --verbose`.
## Support, FAQ and troubleshooting
If you come across any problems or would like to get in touch:
To learn more, troubleshoot issues, or to contact us for support:
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md).
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud).
* For bug reports or feature requests,
[have a look at the GitHub issues or create a new one](https://github.com/balena-io/balena-cli/issues/).
* Check the [masterclass tutorials](https://www.balena.io/docs/learn/more/masterclasses/overview/)
* Check our [FAQ / troubleshooting document](https://github.com/balena-io/balena-cli/blob/master/TROUBLESHOOTING.md)
* Ask us a question through the [balenaCloud forum](https://forums.balena.io/c/balena-cloud)
For CLI bug reports or feature requests, check the
[CLI GitHub issues](https://github.com/balena-io/balena-cli/issues/).
## Deprecation policy
The balena CLI uses [semver versioning](https://semver.org/), with the concepts
of major, minor and patch version releases.
The latest release of the previous major version of the balena CLI will remain
compatible with the balenaCloud backend services for one year from the date when
the next major version is released. For example, balena CLI v10.17.5, as the
The latest release of a major version of the balena CLI will remain compatible with
the balenaCloud backend services for at least one year from the date when the
following major version is released. For example, balena CLI v10.17.5, as the
latest v10 release, would remain compatible with the balenaCloud backend for one
year from the date when v11.0.0 is released.

View File

@ -1,8 +1,6 @@
# FAQ & Troubleshooting
# balena CLI FAQ & Troubleshooting
This document contains some common issues, questions and answers related to the balena CLI.
## Where is my configuration file?
## Where is the balena CLI's configuration file located?
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
Unix based operating systems and Windows respectively.
@ -10,53 +8,43 @@ Unix based operating systems and Windows respectively.
The balena CLI also attempts to read a `balenarc.yml` file in the current directory, which takes
precedence over the per-user configuration file.
## How do I point the balena CLI to staging?
## How do I point the balena CLI to the staging environment?
The easiest way is to set the `BALENARC_BALENA_URL=balena-staging.com` environment variable.
Alternatively, you can edit your configuration file and set `balenaUrl: balena-staging.com` to
persist this setting.
Set the `BALENARC_BALENA_URL=balena-staging.com` environment variable, or add
`balenaUrl: balena-staging.com` to the balena CLI's configuration file.
## How do I make the balena CLI persist data in another directory?
The balena CLI persists your session token, as well as cached images in `$HOME/.balena` or
`%UserProfile%\_balena`.
The balena CLI persists the session token, as well as cached assets, to `$HOME/.balena` or
`%UserProfile%\_balena`. This directory can be changed by setting an environment variable,
`BALENARC_DATA_DIRECTORY=/opt/balena`, or by adding `dataDirectory: /opt/balena` to the CLI's
configuration file, replacing `/opt/balena` with the desired directory.
Pointing the balena CLI to persist data in another location is necessary in certain environments,
like a server, where there is no home directory, or a device running balenaOS, which erases all
data after a restart.
## After burning to an SD card, my device doesn't boot
You can accomplish this by setting `BALENARC_DATA_DIRECTORY=/opt/balena` or adding `dataDirectory:
/opt/balena` to your configuration file, replacing `/opt/balena` with your desired directory.
Check whether the downloaded image is incomplete (download was interrupted) or corrupted.
## After burning to an sdcard, my device doesn't boot
Try clearing the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and running the
command again.
- The downloaded image is not complete (download was interrupted).
## I get a permission error when burning to an SD card
Please clean the cache (`%HOME/.balena/cache` or `C:\Users\<user>\_balena\cache`) and run the command again. In the future, the CLI will check that the image is not complete and clean the cache for you.
Check whether the SD card is locked (a physical switch on the side of the card).
## I get a permission error when burning to an sdcard
## I get EINVAL errors on Cygwin
- The SDCard is locked.
### I get EINVAL errors on Cygwin
The errors look something like this:
The errors may look something like this:
```
net.js:156
this._handle.open(options.fd);
^
Error: EINVAL, invalid argument
at new Socket (net.js:156:18)
at process.stdin (node.js:664:19)
at Object.Interface.createInterface (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\node_modules\readline2\index.js:31:43)
at PromptUI.UI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\baseUI.js:23:40)
at new PromptUI (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\ui\prompt.js:26:8)
at Object.promptModule [as prompt] (C:\cygwin\home\Juan Cruz Viotti\Projects\balena-cli\node_modules\inquirer\lib\inquirer.js:27:14)
```
- Some interactive widgets don't work on `Cygwin`. If you're running Windows, it's preferrable that you use `cmd.exe`, as `Cygwin` is [not official supported by Node.js](https://github.com/chjj/blessed/issues/56#issuecomment-42671945).
Some interactive widgets don't work on `Cygwin`. On Windows, PowerShell or `cmd.exe` are better
supported. Alternative shells are [listed in the README
file](./README.md#choosing-a-shell-command-promptterminal).
## I get `Invalid MBR boot signature` when configuring a device
@ -76,7 +64,9 @@ Or in Windows:
## I get `EACCES: permission denied` when logging in
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based operating systems and Windows respectively. This error usually indicates that the user doesn't have permissions over that directory, which can happen if you ran the balena CLI as `root`, and thus the directory got owned by him.
The balena CLI stores the session token in `$HOME/.balena` or `C:\Users\<user>\_balena` in UNIX based
operating systems and Windows respectively. This error usually indicates that the user doesn't have
permissions over that directory, which can happen if the CLI was executed as the `root` user.
Try resetting the ownership by running:
@ -86,7 +76,15 @@ $ sudo chown -R <user> $HOME/.balena
## Broken line wrapping / cursor behavior with `balena ssh`
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example when long command lines are typed in a `balena ssh` session, or when using text editors like `vim` or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile` and the like), including UTF-8 misconfiguration, the use of unsupported ASCII control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or log files that use colored text. The issue can sometimes be fixed by resizing the client terminal window, or by running one or more of the following commands on the shell:
Users sometimes come across broken line wrapping or cursor behavior in text terminals, for example
when long command lines are typed in a `balena ssh` session, or when using text editors like `vim`
or `nano`. This is not something specific to the balena CLI, being also a commonly reported issue
with standard remote terminal tools like `ssh` or `telnet`. It is often a remote shell
configuration issue (files like `/etc/profile`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`
and the like on the remote machine), including UTF-8 misconfiguration, the use of unsupported ASCII
control characters in shell prompt formatting (e.g. the `$PS1` env var) or the output of tools or
log files that use colored text. The issue can sometimes be fixed by simply resizing the client
terminal window, or by running one or more of the following commands on the shell:
```sh
export TERMINAL=linux
@ -112,10 +110,10 @@ If nothing seems to help, consider also using a different client-side terminal a
## "Docker seems to be unavailable" error when using Windows Subsystem for Linux (WSL)
When running on WSL, the recommendation is to install a CLI release for Linux, like the standalone
zip package for Linux. However, commands like "balena build" that contact a local Docker daemon,
like the Docker Desktop for Windows, will try to reach Docker at the Unix socket path
`/var/run/docker.sock`, while Docker Desktop for Windows uses a Windows named pipe at
`//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A solution is:
zip package for Linux. However, commands like "balena build" will, by default, attempt to reach the
Docker daemon at the Unix socket path `/var/run/docker.sock`, while Docker Desktop for Windows uses
a Windows named pipe at `//./pipe/docker_engine` (which the Linux CLI on WSL cannot use). A
solution is:
- Open the Docker Desktop for Windows settings panel and tick the checkbox _"Expose daemon on tcp://localhost:2375 without TLS"._
- On the WSL command line, set an env var:

View File

@ -15,7 +15,7 @@
* limitations under the License.
*/
import type { JsonVersions } from '../lib/actions-oclif/version';
import type { JsonVersions } from '../lib/commands/version';
import { run as oclifRun } from '@oclif/dev-cli';
import * as archiver from 'archiver';
@ -25,17 +25,18 @@ import * as filehound from 'filehound';
import * as fs from 'fs-extra';
import * as _ from 'lodash';
import * as path from 'path';
import { exec as execPkg } from 'pkg';
import * as rimraf from 'rimraf';
import * as semver from 'semver';
import * as util from 'util';
import { stripIndent } from '../lib/utils/lazy';
import {
diffLines,
getSubprocessStdout,
loadPackageJson,
MSYS2_BASH,
ROOT,
StdOutTap,
whichSpawn,
} from './utils';
@ -73,6 +74,99 @@ export const finalReleaseAssets: { [platform: string]: string[] } = {
linux: [standaloneZips['linux']],
};
/**
* Given the output of `pkg` as a string (containing warning messages),
* diff it against previously saved output of known "safe" warnings.
* Throw an error if the diff is not empty.
*/
async function diffPkgOutput(pkgOut: string) {
const { monochrome } = await import('../tests/helpers');
const relSavedPath = path.join(
'tests',
'test-data',
'pkg',
`expected-warnings-${process.platform}.txt`,
);
const absSavedPath = path.join(ROOT, relSavedPath);
const ignoreStartsWith = [
'> pkg@',
'> Fetching base Node.js binaries',
' fetched-',
];
const modulesRE =
process.platform === 'win32'
? /(?<=[ '])([A-Z]:)?\\.+?\\node_modules(?=\\)/
: /(?<=[ '])\/.+?\/node_modules(?=\/)/;
const buildRE =
process.platform === 'win32'
? /(?<=[ '])([A-Z]:)?\\.+\\build(?=\\)/
: /(?<=[ '])\/.+\/build(?=\/)/;
const cleanLines = (chunks: string | string[]) => {
const lines = typeof chunks === 'string' ? chunks.split('\n') : chunks;
return lines
.map((line: string) => monochrome(line)) // remove ASCII colors
.filter((line: string) => !/^\s*$/.test(line)) // blank lines
.filter((line: string) =>
ignoreStartsWith.every((i) => !line.startsWith(i)),
)
.map((line: string) => {
// replace absolute paths with relative paths
let replaced = line.replace(modulesRE, 'node_modules');
if (replaced === line) {
replaced = line.replace(buildRE, 'build');
}
return replaced;
});
};
pkgOut = cleanLines(pkgOut).join('\n');
const { readFile } = (await import('fs')).promises;
const expectedOut = cleanLines(await readFile(absSavedPath, 'utf8')).join(
'\n',
);
if (expectedOut !== pkgOut) {
const sep =
'================================================================================';
const diff = diffLines(expectedOut, pkgOut);
const msg = `pkg output does not match expected output from "${relSavedPath}"
Diff:
${sep}
${diff}
${sep}
Check whether the new or changed pkg warnings are safe to ignore, then update
"${relSavedPath}"
and share the result of your investigation as comments on the pull request.
Hint: the fix is often a matter of updating the 'pkg.scripts' or 'pkg.assets'
sections in the CLI's 'package.json' file, or a matter of updating the
'buildPkg' function in 'automation/build-bin.ts'. Sometimes it requires
patching dependencies: See for example 'patches/all/open+7.0.2.patch'.
${sep}
`;
throw new Error(msg);
}
}
/**
* Call `pkg.exec` to generate the standalone zip file, capturing its warning
* messages (stdout and stderr) in order to call diffPkgOutput().
*/
async function execPkg(...args: any[]) {
const { exec: pkgExec } = await import('pkg');
const outTap = new StdOutTap(true);
try {
outTap.tap();
await (pkgExec as any)(...args);
} catch (err) {
outTap.untap();
console.log(outTap.stdoutBuf.join(''));
console.error(outTap.stderrBuf.join(''));
throw err;
}
outTap.untap();
await diffPkgOutput(outTap.allBuf.join(''));
}
/**
* Use the 'pkg' module to create a single large executable file with
* the contents of 'node_modules' and the CLI's javascript code.
@ -208,11 +302,11 @@ export async function buildStandaloneZip() {
await buildPkg();
await testPkg();
await zipPkg();
console.log(`Standalone zip package build completed`);
} catch (error) {
console.log(`Error creating or testing standalone zip package:\n ${error}`);
process.exit(1);
console.error(`Error creating or testing standalone zip package`);
throw error;
}
console.log(`Standalone zip package build completed`);
}
async function renameInstallerFiles() {
@ -315,3 +409,12 @@ export async function catchUncommitted(): Promise<void> {
]);
}
}
export async function testShrinkwrap(): Promise<void> {
if (process.env.DEBUG) {
console.error(`[debug] platform=${process.platform}`);
}
if (process.platform !== 'win32') {
await whichSpawn(path.resolve(__dirname, 'test-lock-deduplicated.sh'));
}
}

View File

@ -26,139 +26,153 @@ import { MarkdownFileParser } from './utils';
* some content to this object.
*/
const capitanoDoc = {
title: 'Balena CLI Documentation',
title: 'balena CLI Documentation',
introduction: '',
categories: [
{
title: 'API keys',
files: ['build/actions-oclif/api-key/generate.js'],
files: ['build/commands/api-key/generate.js'],
},
{
title: 'Application',
files: [
'build/actions-oclif/apps.js',
'build/actions-oclif/app/index.js',
'build/actions-oclif/app/create.js',
'build/actions-oclif/app/rm.js',
'build/actions-oclif/app/restart.js',
'build/commands/apps.js',
'build/commands/app/index.js',
'build/commands/app/create.js',
'build/commands/app/purge.js',
'build/commands/app/rename.js',
'build/commands/app/restart.js',
'build/commands/app/rm.js',
],
},
{
title: 'Authentication',
files: [
'build/actions-oclif/login.js',
'build/actions-oclif/logout.js',
'build/actions-oclif/whoami.js',
'build/commands/login.js',
'build/commands/logout.js',
'build/commands/whoami.js',
],
},
{
title: 'Device',
files: [
'build/actions-oclif/device/identify.js',
'build/actions-oclif/device/init.js',
'build/actions-oclif/device/index.js',
'build/actions-oclif/device/move.js',
'build/actions-oclif/device/reboot.js',
'build/actions-oclif/device/register.js',
'build/actions-oclif/device/rename.js',
'build/actions-oclif/device/rm.js',
'build/actions-oclif/device/shutdown.js',
'build/actions-oclif/devices/index.js',
'build/actions-oclif/devices/supported.js',
'build/actions-oclif/device/os-update.js',
'build/actions-oclif/device/public-url.js',
'build/commands/devices/index.js',
'build/commands/devices/supported.js',
'build/commands/device/index.js',
'build/commands/device/identify.js',
'build/commands/device/init.js',
'build/commands/device/move.js',
'build/commands/device/os-update.js',
'build/commands/device/public-url.js',
'build/commands/device/purge.js',
'build/commands/device/reboot.js',
'build/commands/device/register.js',
'build/commands/device/rename.js',
'build/commands/device/restart.js',
'build/commands/device/rm.js',
'build/commands/device/shutdown.js',
],
},
{
title: 'Environment Variables',
files: [
'build/actions-oclif/envs.js',
'build/actions-oclif/env/add.js',
'build/actions-oclif/env/rename.js',
'build/actions-oclif/env/rm.js',
'build/commands/envs.js',
'build/commands/env/add.js',
'build/commands/env/rename.js',
'build/commands/env/rm.js',
],
},
{
title: 'Tags',
files: [
'build/actions-oclif/tags.js',
'build/actions-oclif/tag/rm.js',
'build/actions-oclif/tag/set.js',
'build/commands/tags.js',
'build/commands/tag/rm.js',
'build/commands/tag/set.js',
],
},
{
title: 'Help and Version',
files: ['build/actions/help.js', 'build/actions-oclif/version.js'],
files: ['help', 'build/commands/version.js'],
},
{
title: 'Keys',
files: [
'build/actions-oclif/keys.js',
'build/actions-oclif/key/index.js',
'build/actions-oclif/key/add.js',
'build/actions-oclif/key/rm.js',
'build/commands/keys.js',
'build/commands/key/index.js',
'build/commands/key/add.js',
'build/commands/key/rm.js',
],
},
{
title: 'Logs',
files: ['build/actions-oclif/logs.js'],
files: ['build/commands/logs.js'],
},
{
title: 'Network',
files: [
'build/actions-oclif/scan.js',
'build/actions-oclif/ssh.js',
'build/actions-oclif/tunnel.js',
'build/commands/scan.js',
'build/commands/ssh.js',
'build/commands/tunnel.js',
],
},
{
title: 'Notes',
files: ['build/actions-oclif/note.js'],
files: ['build/commands/note.js'],
},
{
title: 'OS',
files: [
'build/actions/os.js',
'build/actions-oclif/os/configure.js',
'build/actions-oclif/os/versions.js',
'build/actions-oclif/os/download.js',
'build/actions-oclif/os/initialize.js',
'build/commands/os/build-config.js',
'build/commands/os/configure.js',
'build/commands/os/versions.js',
'build/commands/os/download.js',
'build/commands/os/initialize.js',
],
},
{
title: 'Config',
files: ['build/actions/config.js'],
files: [
'build/commands/config/generate.js',
'build/commands/config/inject.js',
'build/commands/config/read.js',
'build/commands/config/reconfigure.js',
'build/commands/config/write.js',
],
},
{
title: 'Preload',
files: ['build/actions/preload.js'],
files: ['build/commands/preload.js'],
},
{
title: 'Push',
files: ['build/actions/push.js'],
files: ['build/commands/push.js'],
},
{
title: 'Settings',
files: ['build/actions-oclif/settings.js'],
files: ['build/commands/settings.js'],
},
{
title: 'Local',
files: [
'build/actions-oclif/local/configure.js',
'build/actions-oclif/local/flash.js',
'build/commands/local/configure.js',
'build/commands/local/flash.js',
],
},
{
title: 'Deploy',
files: ['build/actions/build.js', 'build/actions/deploy.js'],
files: ['build/commands/build.js', 'build/commands/deploy.js'],
},
{
title: 'Platform',
files: ['build/actions-oclif/join.js', 'build/actions-oclif/leave.js'],
files: ['build/commands/join.js', 'build/commands/leave.js'],
},
{
title: 'Utilities',
files: ['build/actions-oclif/util/available-drives.js'],
files: ['build/commands/util/available-drives.js'],
},
{
title: 'Support',
files: ['build/commands/support.js'],
},
],
};
@ -185,7 +199,9 @@ export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
return match && match[2];
}),
mdParser.getSectionOfTitle('Installation'),
mdParser.getSectionOfTitle('Getting Started'),
mdParser.getSectionOfTitle('Choosing a shell (command prompt/terminal)'),
mdParser.getSectionOfTitle('Logging in'),
mdParser.getSectionOfTitle('Proxy support'),
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
mdParser.getSectionOfTitle('Deprecation policy'),
]);

View File

@ -15,7 +15,6 @@
* limitations under the License.
*/
import { Command as OclifCommandClass } from '@oclif/command';
import { CommandDefinition as CapitanoCommand } from 'capitano';
type OclifCommand = typeof OclifCommandClass;
@ -27,7 +26,7 @@ export interface Document {
export interface Category {
title: string;
commands: Array<CapitanoCommand | OclifCommand>;
commands: OclifCommand[];
}
export { CapitanoCommand, OclifCommand };
export { OclifCommand };

View File

@ -14,12 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _ from 'lodash';
import * as path from 'path';
import { getCapitanoDoc } from './capitanodoc';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import { Category, Document, OclifCommand } from './doc-types';
import * as markdown from './markdown';
import { stripIndent } from '../../lib/utils/lazy';
/**
* Generates the markdown document (as a string) for the CLI documentation
@ -40,11 +39,7 @@ export async function renderMarkdown(): Promise<string> {
};
for (const jsFilename of commandCategory.files) {
category.commands.push(
...(jsFilename.includes('actions-oclif')
? importOclifCommands(jsFilename)
: importCapitanoCommands(jsFilename)),
);
category.commands.push(...importOclifCommands(jsFilename));
}
result.categories.push(category);
}
@ -52,27 +47,48 @@ export async function renderMarkdown(): Promise<string> {
return markdown.render(result);
}
function importCapitanoCommands(jsFilename: string): CapitanoCommand[] {
const actions = require(path.join(process.cwd(), jsFilename));
const commands: CapitanoCommand[] = [];
// Help is now managed via a plugin
// This fake command allows capitanodoc to include help in docs
class FakeHelpCommand {
description = stripIndent`
List balena commands, or get detailed help for a specific command.
if (actions.signature) {
commands.push(_.omit(actions, 'action') as any);
} else {
for (const actionName of Object.keys(actions)) {
const actionCommand = actions[actionName];
commands.push(_.omit(actionCommand, 'action') as any);
}
}
return commands;
List balena commands, or get detailed help for a specific command.
`;
examples = [
'$ balena help',
'$ balena help apps',
'$ balena help os download',
];
args = [
{
name: 'command',
description: 'command to show help for',
},
];
usage = 'help [command]';
flags = {
verbose: {
description: 'show additional commands',
char: '-v',
},
};
}
function importOclifCommands(jsFilename: string): OclifCommand[] {
// TODO: Currently oclif commands with no `usage` overridden will cause
// an error when parsed. This should be improved so that `usage` does not have
// to be overridden if not necessary.
const command: OclifCommand = require(path.join(process.cwd(), jsFilename))
.default as OclifCommand;
const command: OclifCommand =
jsFilename === 'help'
? ((new FakeHelpCommand() as unknown) as OclifCommand)
: (require(path.join(process.cwd(), jsFilename)).default as OclifCommand);
return [command];
}

View File

@ -20,33 +20,10 @@ import * as _ from 'lodash';
import { getManualSortCompareFunction } from '../../lib/utils/helpers';
import { capitanoizeOclifUsage } from '../../lib/utils/oclif-utils';
import { CapitanoCommand, Category, Document, OclifCommand } from './doc-types';
import * as utils from './utils';
function renderCapitanoCommand(command: CapitanoCommand): string[] {
const result = [`## ${ent.encode(command.signature)}`, command.help!];
if (!_.isEmpty(command.options)) {
result.push('### Options');
for (const option of command.options!) {
if (option == null) {
throw new Error(`Undefined option in markdown generation!`);
}
if (option.description == null) {
throw new Error(`Undefined option.description in markdown generation!`);
}
result.push(
`#### ${utils.parseCapitanoOption(option)}`,
option.description,
);
}
}
return result;
}
import { Category, Document, OclifCommand } from './doc-types';
function renderOclifCommand(command: OclifCommand): string[] {
const result = [`## ${ent.encode(command.usage)}`];
const result = [`## ${ent.encode(command.usage || '')}`];
const description = (command.description || '')
.split('\n')
.slice(1) // remove the first line, which oclif uses as help header
@ -86,11 +63,7 @@ function renderOclifCommand(command: OclifCommand): string[] {
function renderCategory(category: Category): string[] {
const result = [`# ${category.title}`];
for (const command of category.commands) {
result.push(
...(typeof command === 'object'
? renderCapitanoCommand(command)
: renderOclifCommand(command)),
);
result.push(...renderOclifCommand(command));
}
return result;
}
@ -107,10 +80,7 @@ function renderToc(categories: Category[]): string[] {
result.push(
category.commands
.map((command) => {
const signature =
typeof command === 'object'
? command.signature // Capitano
: capitanoizeOclifUsage(command.usage); // oclif
const signature = capitanoizeOclifUsage(command.usage);
return `\t- [${ent.encode(signature)}](${getAnchor(signature)})`;
})
.join('\n'),
@ -134,12 +104,10 @@ function sortCommands(doc: Document): void {
for (const category of doc.categories) {
if (category.title in manualCategorySorting) {
category.commands = category.commands.sort(
getManualSortCompareFunction<CapitanoCommand | OclifCommand, string>(
getManualSortCompareFunction<OclifCommand, string>(
manualCategorySorting[category.title],
(cmd: CapitanoCommand | OclifCommand, x: string) =>
typeof cmd === 'object' // Capitano vs oclif command
? cmd.signature.replace(/\W+/g, ' ').includes(x)
: (cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
(cmd: OclifCommand, x: string) =>
(cmd.usage || '').toString().replace(/\W+/g, ' ').includes(x),
),
);
}

View File

@ -21,6 +21,7 @@ import {
buildOclifInstaller,
buildStandaloneZip,
catchUncommitted,
testShrinkwrap,
} from './build-bin';
import {
release,
@ -63,6 +64,7 @@ export async function run(args?: string[]) {
'build:installer': buildOclifInstaller,
'build:standalone': buildStandaloneZip,
'catch-uncommitted': catchUncommitted,
'test-shrinkwrap': testShrinkwrap,
fix1359: updateDescriptionOfReleasesAffectedByIssue1359,
release,
};

View File

@ -0,0 +1,17 @@
#!/bin/bash
set -e
cp npm-shrinkwrap.json npm-shrinkwrap.json.old
npm i
npm dedupe
npm i
if ! diff -q npm-shrinkwrap.json npm-shrinkwrap.json.old > /dev/null; then
rm npm-shrinkwrap.json.old
echo "** npm-shrinkwrap.json was not deduplicated or not fully committed - FAIL **";
echo "** Please run 'npm ci', followed by 'npm dedupe' **";
exit 1;
fi
rm npm-shrinkwrap.json.old

View File

@ -23,6 +23,69 @@ import * as shellEscape from 'shell-escape';
export const MSYS2_BASH = 'C:\\msys64\\usr\\bin\\bash.exe';
export const ROOT = path.join(__dirname, '..');
/** Tap and buffer this process' stdout and stderr */
export class StdOutTap {
public stdoutBuf: string[] = [];
public stderrBuf: string[] = [];
public allBuf: string[] = []; // both stdout and stderr
protected origStdoutWrite: typeof process.stdout.write;
protected origStderrWrite: typeof process.stdout.write;
constructor(protected printDots = false) {}
tap() {
this.origStdoutWrite = process.stdout.write;
this.origStderrWrite = process.stderr.write;
process.stdout.write = (chunk: string, ...args: any[]): boolean => {
this.stdoutBuf.push(chunk);
this.allBuf.push(chunk);
const str = this.printDots ? '.' : chunk;
return this.origStdoutWrite.call(process.stdout, str, ...args);
};
process.stderr.write = (chunk: string, ...args: any[]): boolean => {
this.stderrBuf.push(chunk);
this.allBuf.push(chunk);
const str = this.printDots ? '.' : chunk;
return this.origStderrWrite.call(process.stderr, str, ...args);
};
}
untap() {
process.stdout.write = this.origStdoutWrite;
process.stderr.write = this.origStderrWrite;
if (this.printDots) {
console.error('');
}
}
}
/**
* Diff strings by line, using the 'diff' npm package:
* https://www.npmjs.com/package/diff
*/
export function diffLines(str1: string, str2: string): string {
const { diffTrimmedLines } = require('diff');
const diffObjs = diffTrimmedLines(str1, str2);
const prefix = (chunk: string, char: string) =>
chunk
.split('\n')
.map((line: string) => `${char} ${line}`)
.join('\n');
const diffStr = diffObjs
.map((part: any) => {
return part.added
? prefix(part.value, '+')
: part.removed
? prefix(part.value, '-')
: prefix(part.value, ' ');
})
.join('\n');
return diffStr;
}
export function loadPackageJson() {
return require(path.join(ROOT, 'package.json'));
}
@ -143,7 +206,7 @@ export async function which(program: string): Promise<string> {
*/
export async function whichSpawn(
programName: string,
args: string[],
args?: string[],
): Promise<void> {
const program = await which(programName);
let error: Error | undefined;

View File

@ -1,5 +1,7 @@
#!/usr/bin/env node
// tslint:disable:no-var-requires
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
@ -10,7 +12,7 @@ process.env.OCLIF_TS_NODE = 0;
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
cacheFile: __dirname + '/.fast-boot.json'
cacheFile: __dirname + '/.fast-boot.json',
});
// Set the desired es version for downstream modules that support it

View File

@ -1,14 +1,31 @@
#!/usr/bin/env node
// ****************************************************************************
// THIS IS FOR DEV PERROSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
// THIS IS FOR DEV PURPOSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
// Before opening a PR you should build and test your changes using bin/balena
// ****************************************************************************
// tslint:disable:no-var-requires
// We boost the threadpool size as ext2fs can deadlock with some
// operations otherwise, if the pool runs out.
process.env.UV_THREADPOOL_SIZE = '64';
const path = require('path');
const rootDir = path.join(__dirname, '..');
// Allow balena-dev to work with oclif by temporarily
// pointing oclif config options to lib/ instead of build/
modifyOclifPaths();
// Undo changes on exit
process.on('exit', function () {
modifyOclifPaths(true);
});
// Undo changes in case of ctrl-v
process.on('SIGINT', function () {
modifyOclifPaths(true);
});
// Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({
cacheScope: __dirname + '/..',
@ -18,8 +35,6 @@ require('fast-boot2').start({
// Set the desired es version for downstream modules that support it
require('@balena/es-version').set('es2018');
const path = require('path');
const rootDir = path.join(__dirname, '..');
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
// default option. We upgraded ts-node and found that adding 'transpile-only'
// was necessary to avoid a mysterious 'null' error message. On the plus side,
@ -30,3 +45,30 @@ require('ts-node').register({
transpileOnly: true,
});
require('../lib/app').run();
// Modify package.json oclif paths from build/ -> lib/, or vice versa
function modifyOclifPaths(revert) {
const fs = require('fs');
const packageJsonPath = path.join(rootDir, 'package.json');
const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
const packageObj = JSON.parse(packageJson);
if (!packageObj.oclif) {
return;
}
let oclifSectionText = JSON.stringify(packageObj.oclif);
if (!revert) {
oclifSectionText = oclifSectionText.replace(/\/build\//g, '/lib/');
} else {
oclifSectionText = oclifSectionText.replace(/\/lib\//g, '/build/');
}
packageObj.oclif = JSON.parse(oclifSectionText);
fs.writeFileSync(
packageJsonPath,
`${JSON.stringify(packageObj, null, 2)}\n`,
'utf8',
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,206 +0,0 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as dockerUtils from '../utils/docker';
import * as compose from '../utils/compose';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import { getBalenaSdk } from '../utils/lazy';
/**
* Opts must be an object with the following keys:
* app: the app this build is for (optional)
* arch: the architecture to build for
* deviceType: the device type to build for
* buildEmulated
* buildOpts: arguments to forward to docker build command
*
* @param {import('docker-toolbelt')} docker
* @param {import('../utils/logger')} logger
* @param {import('../utils/compose-types').ComposeOpts} composeOpts
* @param {any} opts
*/
const buildProject = function (docker, logger, composeOpts, opts) {
const { loadProject } = require('../utils/compose_ts');
return loadProject(logger, composeOpts)
.then(function (project) {
const appType = opts.app?.application_type?.[0];
if (
appType != null &&
project.descriptors.length > 1 &&
!appType.supports_multicontainer
) {
logger.logWarn(
'Target application does not support multiple containers.\n' +
'Continuing with build, but you will not be able to deploy.',
);
}
return compose.buildProject(
docker,
logger,
project.path,
project.name,
project.composition,
opts.arch,
opts.deviceType,
opts.buildEmulated,
opts.buildOpts,
composeOpts.inlineLogs,
composeOpts.convertEol,
composeOpts.dockerfilePath,
composeOpts.nogitignore,
composeOpts.multiDockerignore,
);
})
.then(function () {
logger.outputDeferredMessages();
logger.logSuccess('Build succeeded!');
})
.catch((err) => {
logger.logError('Build failed');
throw err;
});
};
export const build = {
signature: 'build [source]',
description: 'Build a single image or a multicontainer project locally',
primary: true,
help: `\
Use this command to build an image or a complete multicontainer project with
the provided docker daemon in your development machine or balena device.
(See also the \`balena push\` command for the option of building images in the
balenaCloud build servers.)
You must provide either an application or a device-type/architecture pair to use
the balena Dockerfile pre-processor (e.g. Dockerfile.template -> Dockerfile).
This command will look into the given source directory (or the current working
directory if one isn't specified) for a docker-compose.yml file, and if found,
each service defined in the compose file will be built. If a compose file isn't
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
will try to generate one.
${registrySecretsHelp}
${dockerignoreHelp}
Examples:
$ balena build
$ balena build ./source/
$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated
$ balena build --application MyApp ./source/
$ balena build --docker /var/run/docker.sock # Linux, Mac
$ balena build --docker //./pipe/docker_engine # Windows
$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem\
`,
options: dockerUtils.appendOptions(
compose.appendOptions([
{
signature: 'arch',
parameter: 'arch',
description: 'The architecture to build for',
alias: 'A',
},
{
signature: 'deviceType',
parameter: 'deviceType',
description: 'The type of device this build is for',
alias: 'd',
},
{
signature: 'application',
parameter: 'application',
description: 'The target balena application this build is for',
alias: 'a',
},
]),
),
async action(params, options) {
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
require('events').defaultMaxListeners = 1000;
const sdk = getBalenaSdk();
const { ExpectedError } = require('../errors');
const { checkLoggedIn } = require('../utils/patterns');
const { validateProjectDirectory } = require('../utils/compose_ts');
const helpers = require('../utils/helpers');
const Logger = require('../utils/logger');
const logger = Logger.getLogger();
logger.logDebug('Parsing input...');
// `build` accepts `[source]` as a parameter, but compose expects it
// as an option. swap them here
if (options.source == null) {
options.source = params.source;
}
delete params.source;
const { application, arch, deviceType } = options;
if (
(application == null && (arch == null || deviceType == null)) ||
(application != null && (arch != null || deviceType != null))
) {
throw new ExpectedError(
'You must specify either an application or an arch/deviceType pair to build for',
);
}
if (application) {
await checkLoggedIn();
}
return validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
})
.then(function ({ dockerfilePath, registrySecrets }) {
options.dockerfile = dockerfilePath;
options['registry-secrets'] = registrySecrets;
if (arch != null && deviceType != null) {
return [undefined, arch, deviceType];
} else {
return helpers
.getAppWithArch(application)
.then((app) => [app, app.arch, app.device_type]);
}
})
.then(function ([app, resolvedArch, resolvedDeviceType]) {
return Promise.all([
dockerUtils.getDocker(options),
dockerUtils.generateBuildOpts(options),
compose.generateOpts(options),
]).then(([docker, buildOpts, composeOpts]) =>
buildProject(docker, logger, composeOpts, {
app,
arch: resolvedArch,
deviceType: resolvedDeviceType,
buildEmulated: !!options.emulated,
buildOpts,
}),
);
});
},
};

View File

@ -1,153 +0,0 @@
/*
Copyright 2016-2017 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const yes = {
signature: 'yes',
description: 'confirm non interactively',
boolean: true,
alias: 'y',
};
export interface YesOption {
yes: boolean;
}
export const optionalApplication = {
signature: 'application',
parameter: 'application',
description: 'application name',
alias: ['a', 'app'],
};
export const application = {
...optionalApplication,
required: 'You have to specify an application',
};
export const optionalRelease = {
signature: 'release',
parameter: 'release',
description: 'release id',
alias: 'r',
};
export const optionalDevice = {
signature: 'device',
parameter: 'device',
description: 'device uuid',
alias: 'd',
};
export const optionalDeviceApiKey = {
signature: 'deviceApiKey',
description:
'custom device key - note that this is only supported on balenaOS 2.0.3+',
parameter: 'device-api-key',
alias: 'k',
};
export const optionalDeviceType = {
signature: 'deviceType',
description: 'device type slug',
parameter: 'device-type',
};
export const optionalOsVersion = {
signature: 'version',
description: 'a balenaOS version',
parameter: 'version',
};
export type OptionalOsVersionOption = Partial<OsVersionOption>;
export const osVersion = {
...exports.optionalOsVersion,
required: 'You have to specify an exact os version',
};
export interface OsVersionOption {
version?: string;
}
export const booleanDevice = {
signature: 'device',
description: 'device',
boolean: true,
alias: 'd',
};
export const osVersionOrSemver = {
signature: 'version',
description: `\
exact version number, or a valid semver range,
or 'latest' (includes pre-releases),
or 'default' (excludes pre-releases if at least one stable version is available),
or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available),
or 'menu' (will show the interactive menu)\
`,
parameter: 'version',
};
export const network = {
signature: 'network',
parameter: 'network',
description: 'network type',
alias: 'n',
};
export const wifiSsid = {
signature: 'ssid',
parameter: 'ssid',
description: 'wifi ssid, if network is wifi',
alias: 's',
};
export const wifiKey = {
signature: 'key',
parameter: 'key',
description: 'wifi key, if network is wifi',
alias: 'k',
};
export const forceUpdateLock = {
signature: 'force',
description: 'force action if the update lock is set',
boolean: true,
alias: 'f',
};
export const drive = {
signature: 'drive',
description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \
Careful with this as you can erase your hard drive. \
Check \`balena util available-drives\` for available options.`,
parameter: 'drive',
alias: 'd',
};
export const advancedConfig = {
signature: 'advanced',
description: 'show advanced configuration options',
boolean: true,
alias: 'v',
};
export const hostOSAccess = {
signature: 'host',
boolean: true,
description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)',
alias: 's',
};

View File

@ -1,408 +0,0 @@
/*
Copyright 2016-2020 Balena Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as commandOptions from './command-options';
import { normalizeUuidProp } from '../utils/normalization';
import { getBalenaSdk, getVisuals, getCliForm } from '../utils/lazy';
import * as _ from 'lodash';
const getUmountAsync = async () => {
const { promisify } = await import('bluebird');
const { umount } = await import('umount');
return promisify(umount);
};
export const read = {
signature: 'config read',
description: 'read a device configuration',
help: `\
Use this command to read the config.json file from the mounted filesystem (e.g. SD card) of a provisioned device"
Examples:
$ balena config read --type raspberry-pi
$ balena config read --type raspberry-pi --drive /dev/disk2\
`,
options: [
{
signature: 'type',
description:
'device type (Check available types with `balena devices supported`)',
parameter: 'type',
alias: 't',
required: 'You have to specify a device type',
},
{
signature: 'drive',
description: 'drive',
parameter: 'drive',
alias: 'd',
},
],
permission: 'user',
root: true,
async action(_params, options) {
const umountAsync = await getUmountAsync();
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
const prettyjson = await import('prettyjson');
console.info(prettyjson.render(configJSON));
},
};
export const write = {
signature: 'config write <key> <value>',
description: 'write a device configuration',
help: `\
Use this command to write the config.json file to the mounted filesystem (e.g. SD card) of a provisioned device
Examples:
$ balena config write --type raspberry-pi username johndoe
$ balena config write --type raspberry-pi --drive /dev/disk2 username johndoe
$ balena config write --type raspberry-pi files.network/settings "..."\
`,
options: [
{
signature: 'type',
description:
'device type (Check available types with `balena devices supported`)',
parameter: 'type',
alias: 't',
required: 'You have to specify a device type',
},
{
signature: 'drive',
description: 'drive',
parameter: 'drive',
alias: 'd',
},
],
permission: 'user',
root: true,
async action(params, options) {
const umountAsync = await getUmountAsync();
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
console.info(`Setting ${params.key} to ${params.value}`);
_.set(configJSON, params.key, params.value);
await umountAsync(drive);
await config.write(drive, options.type, configJSON);
console.info('Done');
},
};
export const inject = {
signature: 'config inject <file>',
description: 'inject a device configuration file',
help: `\
Use this command to inject a config.json file to the mounted filesystem
(e.g. SD card or mounted balenaOS image) of a provisioned device"
Examples:
$ balena config inject my/config.json --type raspberry-pi
$ balena config inject my/config.json --type raspberry-pi --drive /dev/disk2\
`,
options: [
{
signature: 'type',
description:
'device type (Check available types with `balena devices supported`)',
parameter: 'type',
alias: 't',
required: 'You have to specify a device type',
},
{
signature: 'drive',
description: 'drive',
parameter: 'drive',
alias: 'd',
},
],
permission: 'user',
root: true,
async action(params, options) {
const umountAsync = await getUmountAsync();
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const fs = await import('fs');
const configJSON = JSON.parse(
await fs.promises.readFile(params.file, 'utf8'),
);
const config = await import('balena-config-json');
await config.write(drive, options.type, configJSON);
console.info('Done');
},
};
export const reconfigure = {
signature: 'config reconfigure',
description: 'reconfigure a provisioned device',
help: `\
Use this command to reconfigure a provisioned device
Examples:
$ balena config reconfigure --type raspberry-pi
$ balena config reconfigure --type raspberry-pi --advanced
$ balena config reconfigure --type raspberry-pi --drive /dev/disk2\
`,
options: [
{
signature: 'type',
description:
'device type (Check available types with `balena devices supported`)',
parameter: 'type',
alias: 't',
required: 'You have to specify a device type',
},
{
signature: 'drive',
description: 'drive',
parameter: 'drive',
alias: 'd',
},
{
signature: 'advanced',
description: 'show advanced commands',
boolean: true,
alias: 'v',
},
],
permission: 'user',
root: true,
async action(_params, options) {
const umountAsync = await getUmountAsync();
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const { uuid } = await config.read(drive, options.type);
await umountAsync(drive);
let configureCommand = ['os', 'configure', drive, '--device', uuid];
if (options.advanced) {
configureCommand.push('--advanced');
}
const { runCommand } = await import('../utils/helpers');
await runCommand(configureCommand);
console.info('Done');
},
};
export const generate = {
signature: 'config generate',
description: 'generate a config.json file',
help: `\
Use this command to generate a config.json for a device or application.
Calling this command with the exact version number of the targeted image is required.
This is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions
that will be asked for the relevant device type.
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.
Examples:
$ balena config generate --device 7cf02a6 --version 2.12.7
$ balena config generate --device 7cf02a6 --version 2.12.7 --generate-device-api-key
$ balena config generate --device 7cf02a6 --version 2.12.7 --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 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\
`,
options: [
commandOptions.osVersion,
commandOptions.optionalApplication,
commandOptions.optionalDevice,
commandOptions.optionalDeviceApiKey,
commandOptions.optionalDeviceType,
{
signature: 'generate-device-api-key',
description: 'generate a fresh device key for the device',
boolean: true,
},
{
signature: 'output',
description: 'output',
parameter: 'output',
alias: 'o',
},
// Options for non-interactive configuration
{
signature: 'network',
description: 'the network type to use: ethernet or wifi',
parameter: 'network',
},
{
signature: 'wifiSsid',
description:
'the wifi ssid to use (used only if --network is set to wifi)',
parameter: 'wifiSsid',
},
{
signature: 'wifiKey',
description:
'the wifi key to use (used only if --network is set to wifi)',
parameter: 'wifiKey',
},
{
signature: 'appUpdatePollInterval',
description:
'how frequently (in minutes) to poll for application updates',
parameter: 'appUpdatePollInterval',
},
],
permission: 'user',
action(_params, options) {
normalizeUuidProp(options, 'device');
const Bluebird = require('bluebird');
const balena = getBalenaSdk();
const prettyjson = require('prettyjson');
const {
generateDeviceConfig,
generateApplicationConfig,
} = require('../utils/config');
const helpers = require('../utils/helpers');
const { exitWithExpectedError } = require('../errors');
if (options.device == null && options.application == null) {
exitWithExpectedError(`\
You have to pass either a device or an application.
See the help page for examples:
$ balena help config generate\
`);
}
if (!options.application && options.deviceType) {
exitWithExpectedError(`\
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\
`);
}
return Bluebird.try(
/** @returns {Promise<any>} */ function () {
if (options.device != null) {
return balena.models.device.get(options.device);
}
return balena.models.application.get(options.application);
},
)
.then(function (resource) {
const deviceType = options.deviceType || resource.device_type;
let manifestPromise = balena.models.device.getManifestBySlug(
deviceType,
);
if (options.application && options.deviceType) {
const app = resource;
const appManifestPromise = balena.models.device.getManifestBySlug(
app.device_type,
);
manifestPromise = manifestPromise.tap((paramDeviceType) =>
appManifestPromise.then(function (appDeviceType) {
if (
!helpers.areDeviceTypesCompatible(
appDeviceType,
paramDeviceType,
)
) {
throw new balena.errors.BalenaInvalidDeviceType(
`Device type ${options.deviceType} is incompatible with application ${options.application}`,
);
}
}),
);
}
return manifestPromise
.get('options')
.then((
formOptions, // Pass params as an override: if there is any param with exactly the same name as a
) =>
// required option, that value is used (and the corresponding question is not asked)
getCliForm().run(formOptions, { override: options }),
)
.then(function (answers) {
answers.version = options.version;
if (resource.uuid != null) {
return generateDeviceConfig(
resource,
options.deviceApiKey || options['generate-device-api-key'],
answers,
);
} else {
answers.deviceType = deviceType;
return generateApplicationConfig(resource, answers);
}
});
})
.then(function (config) {
if (options.output != null) {
return require('fs').promises.writeFile(
options.output,
JSON.stringify(config),
);
}
console.log(prettyjson.render(config));
});
},
};

View File

@ -1,318 +0,0 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as dockerUtils from '../utils/docker';
import * as compose from '../utils/compose';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy';
/**
* Opts must be an object with the following keys:
* app: the application instance to deploy to
* image: the image to deploy; optional
* dockerfilePath: name of an alternative Dockerfile; optional
* shouldPerformBuild
* shouldUploadLogs
* buildEmulated
* buildOpts: arguments to forward to docker build command
*
* @param {any} docker
* @param {import('../utils/logger')} logger
* @param {import('../utils/compose-types').ComposeOpts} composeOpts
* @param {any} opts
*/
const deployProject = function (docker, logger, composeOpts, opts) {
const Bluebird = require('bluebird');
const _ = require('lodash');
const doodles = require('resin-doodles');
const sdk = getBalenaSdk();
const {
deployProject: $deployProject,
loadProject,
} = require('../utils/compose_ts');
return loadProject(logger, composeOpts, opts.image)
.then(function (project) {
if (
project.descriptors.length > 1 &&
!opts.app.application_type?.[0]?.supports_multicontainer
) {
throw new ExpectedError(
'Target application does not support multiple containers. Aborting!',
);
}
// find which services use images that already exist locally
return (
Bluebird.map(project.descriptors, function (d) {
// unconditionally build (or pull) if explicitly requested
if (opts.shouldPerformBuild) {
return d;
}
return docker
.getImage(typeof d.image === 'string' ? d.image : d.image.tag)
.inspect()
.return(d.serviceName)
.catchReturn();
})
.filter((d) => !!d)
.then(function (servicesToSkip) {
// multibuild takes in a composition and always attempts to
// build or pull all services. we workaround that here by
// passing a modified composition.
const compositionToBuild = _.cloneDeep(project.composition);
compositionToBuild.services = _.omit(
compositionToBuild.services,
servicesToSkip,
);
if (_.size(compositionToBuild.services) === 0) {
logger.logInfo(
'Everything is up to date (use --build to force a rebuild)',
);
return {};
}
return compose
.buildProject(
docker,
logger,
project.path,
project.name,
compositionToBuild,
opts.app.arch,
opts.app.device_type,
opts.buildEmulated,
opts.buildOpts,
composeOpts.inlineLogs,
composeOpts.convertEol,
composeOpts.dockerfilePath,
composeOpts.nogitignore,
composeOpts.multiDockerignore,
)
.then((builtImages) => _.keyBy(builtImages, 'serviceName'));
})
.then((builtImages) =>
project.descriptors.map(
(d) =>
builtImages[d.serviceName] ?? {
serviceName: d.serviceName,
name: typeof d.image === 'string' ? d.image : d.image.tag,
logs: 'Build skipped; image for service already exists.',
props: {},
},
),
)
// @ts-ignore slightly different return types of partial vs non-partial release
.then(function (images) {
if (opts.app.application_type?.[0]?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy');
const msg = getChalk().yellow(
'Target application requires legacy deploy method.',
);
logger.logWarn(msg);
return Bluebird.join(
docker,
logger,
sdk.auth.getToken(),
sdk.auth.whoami(),
sdk.settings.get('balenaUrl'),
{
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
appName: opts.appName,
imageName: images[0].name,
buildLogs: images[0].logs,
shouldUploadLogs: opts.shouldUploadLogs,
},
deployLegacy,
).then((releaseId) =>
// @ts-ignore releaseId should be inferred as a number because that's what deployLegacy is
// typed as returning but the .js type-checking doesn't manage to infer it correctly due to
// Promise.join typings
sdk.models.release.get(releaseId, { $select: ['commit'] }),
);
}
return Promise.all([
sdk.auth.getUserId(),
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
]).then(([userId, auth, apiEndpoint]) =>
$deployProject(
docker,
logger,
project.composition,
images,
opts.app.id,
userId,
`Bearer ${auth}`,
apiEndpoint,
!opts.shouldUploadLogs,
),
);
})
);
})
.then(function (release) {
logger.outputDeferredMessages();
logger.logSuccess('Deploy succeeded!');
logger.logSuccess(`Release: ${release.commit}`);
console.log();
console.log(doodles.getDoodle()); // Show charlie
console.log();
})
.catch((err) => {
logger.logError('Deploy failed');
throw err;
});
};
export const deploy = {
signature: 'deploy <appName> [image]',
description:
'Deploy a single image or a multicontainer project to a balena application',
help: `\
Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
Use this command to deploy an image or a complete multicontainer project to an
application, optionally building it first. The source images are searched for
(and optionally built) using the docker daemon in your development machine or
balena device. (See also the \`balena push\` command for the option of building
the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a docker-compose.yml file. If one is
found, this command will deploy each service defined in the compose file,
building it first if an image for it doesn't exist. If a compose file isn't
found, the command will look for a Dockerfile[.template] file (or alternative
Dockerfile specified with the \`-f\` option), and if yet that isn't found, it
will try to generate one.
To deploy to an app on which you're a collaborator, use
\`balena deploy <appOwnerUsername>/<appName>\`.
When --build is used, all options supported by \`balena build\` are also supported
by this command.
${registrySecretsHelp}
${dockerignoreHelp}
Examples:
$ balena deploy myApp
$ balena deploy myApp --build --source myBuildDir/
$ balena deploy myApp myApp/myImage\
`,
permission: 'user',
primary: true,
options: dockerUtils.appendOptions(
compose.appendOptions([
{
signature: 'source',
parameter: 'source',
description:
'Specify an alternate source directory; default is the working directory',
alias: 's',
},
{
signature: 'build',
boolean: true,
description: 'Force a rebuild before deploy',
alias: 'b',
},
{
signature: 'nologupload',
description:
"Don't upload build logs to the dashboard with image (if building)",
boolean: true,
},
]),
),
async action(params, options) {
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
require('events').defaultMaxListeners = 1000;
const sdk = getBalenaSdk();
const {
getRegistrySecrets,
validateProjectDirectory,
} = require('../utils/compose_ts');
const helpers = require('../utils/helpers');
const Logger = require('../utils/logger');
const logger = Logger.getLogger();
logger.logDebug('Parsing input...');
// when Capitano converts a positional parameter (but not an option)
// to a number, the original value is preserved with the _raw suffix
let { appName, appName_raw, image } = params;
// look into "balena build" options if appName isn't given
appName = appName_raw || appName || options.application;
delete options.application;
if (appName == null) {
throw new ExpectedError(
'Please specify the name of the application to deploy',
);
}
if (image != null && options.build) {
throw new ExpectedError(
'Build option is not applicable when specifying an image',
);
}
if (image) {
const registrySecrets = await getRegistrySecrets(
sdk,
options['registry-secrets'],
);
options['registry-secrets'] = registrySecrets;
} else {
const {
dockerfilePath,
registrySecrets,
} = await validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
options.dockerfile = dockerfilePath;
options['registry-secrets'] = registrySecrets;
}
const app = await helpers.getAppWithArch(appName);
const [docker, buildOpts, composeOpts] = await Promise.all([
dockerUtils.getDocker(options),
dockerUtils.generateBuildOpts(options),
compose.generateOpts(options),
]);
await deployProject(docker, logger, composeOpts, {
app,
appName, // may be prefixed by 'owner/', unlike app.app_name
image,
shouldPerformBuild: !!options.build,
shouldUploadLogs: !options.nologupload,
buildEmulated: !!options.emulated,
buildOpts,
});
},
};

View File

@ -1,190 +0,0 @@
/*
Copyright 2016-2020 Balena Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as _ from 'lodash';
import * as capitano from 'capitano';
import * as columnify from 'columnify';
import * as messages from '../utils/messages';
import { getManualSortCompareFunction } from '../utils/helpers';
import { exitWithExpectedError } from '../errors';
import { getOclifHelpLinePairs } from './help_ts';
const parse = (object) =>
_.map(object, function (item) {
// Hacky way to determine if an object is
// a function or a command
let signature;
if (item.alias != null) {
signature = item.toString();
} else {
signature = item.signature.toString();
}
return [signature, item.description];
});
const indent = function (text) {
text = _.map(text.split('\n'), (line) => ' ' + line);
return text.join('\n');
};
const print = (usageDescriptionPairs) =>
console.log(
indent(
columnify(_.fromPairs(usageDescriptionPairs), {
showHeaders: false,
minWidth: 35,
}),
),
);
const manuallySortedPrimaryCommands = [
'help',
'login',
'push',
'logs',
'ssh',
'apps',
'app',
'devices',
'device',
'tunnel',
'preload',
'build',
'deploy',
'join',
'leave',
'local scan',
];
const general = function (_params, options, done) {
console.log('Usage: balena [COMMAND] [OPTIONS]\n');
console.log('Primary commands:\n');
// We do not want the wildcard command
// to be printed in the help screen.
const commands = capitano.state.commands.filter(
(command) => !command.hidden && !command.isWildcard(),
);
const capitanoCommands = _.groupBy(commands, function (command) {
if (command.primary) {
return 'primary';
}
return 'secondary';
});
return getOclifHelpLinePairs()
.then(function (oclifHelpLinePairs) {
const primaryHelpLinePairs = parse(capitanoCommands.primary)
.concat(oclifHelpLinePairs.primary)
.sort(
getManualSortCompareFunction(manuallySortedPrimaryCommands, function (
[signature],
manualItem,
) {
return (
signature === manualItem || signature.startsWith(`${manualItem} `)
);
}),
);
const secondaryHelpLinePairs = parse(capitanoCommands.secondary)
.concat(oclifHelpLinePairs.secondary)
.sort();
print(primaryHelpLinePairs);
if (options.verbose) {
console.log('\nAdditional commands:\n');
print(secondaryHelpLinePairs);
} else {
console.log(
'\nRun `balena help --verbose` to list additional commands',
);
}
if (!_.isEmpty(capitano.state.globalOptions)) {
console.log('\nGlobal Options:\n');
print(parse(capitano.state.globalOptions).sort());
}
console.log(indent('--debug\n'));
console.log(messages.help);
return done();
})
.catch(done);
};
const commandHelp = (params, _options, done) =>
capitano.state.getMatchCommand(params.command, function (error, command) {
if (error != null) {
return done(error);
}
if (command == null || command.isWildcard()) {
exitWithExpectedError(`Command not found: ${params.command}`);
}
console.log(`Usage: ${command.signature}`);
if (command.help != null) {
console.log(`\n${command.help}`);
} else if (command.description != null) {
console.log(`\n${_.capitalize(command.description)}`);
}
if (!_.isEmpty(command.options)) {
console.log('\nOptions:\n');
print(parse(command.options).sort());
}
console.log();
return done();
});
export const help = {
signature: 'help [command...]',
description: 'show help',
help: `\
Get detailed help for an specific command.
Examples:
$ balena help apps
$ balena help os download\
`,
primary: true,
options: [
{
signature: 'verbose',
description: 'show additional commands',
boolean: true,
alias: 'v',
},
],
action(params, options, done) {
if (params.command != null) {
return commandHelp(params, options, done);
} else {
return general(params, options, done);
}
},
};

View File

@ -1,65 +0,0 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as _ from 'lodash';
import * as path from 'path';
import Command from '../command';
import { capitanoizeOclifUsage } from '../utils/oclif-utils';
export async function getOclifHelpLinePairs() {
const { convertedCommands } = await import('../preparser');
const primary: Array<[string, string]> = [];
const secondary: Array<[string, string]> = [];
for (const convertedCmd of convertedCommands) {
const [topic, cmd] = convertedCmd.split(':');
const pathComponents = ['..', 'actions-oclif', topic];
if (cmd) {
pathComponents.push(cmd);
}
const cmdModule = await import(path.join(...pathComponents));
const command: typeof Command = cmdModule.default;
if (!command.hidden) {
if (command.primary) {
primary.push(getCmdUsageDescriptionLinePair(command));
} else {
secondary.push(getCmdUsageDescriptionLinePair(command));
}
}
}
return { primary, secondary };
}
function getCmdUsageDescriptionLinePair(cmd: typeof Command): [string, string] {
const usage = capitanoizeOclifUsage(cmd.usage);
let description = '';
// note: [^] matches any characters (including line breaks), achieving the
// same effect as the 's' regex flag which is only supported by Node 9+
const matches = /\s*([^]+?)\n[^]*/.exec(cmd.description || '');
if (matches && matches.length > 1) {
description = _.trimEnd(matches[1], '.');
// Only do .lowerFirst() if the second char is not uppercase (e.g. for 'SSH');
if (description[1] !== description[1]?.toUpperCase()) {
description = _.lowerFirst(description);
}
}
return [usage, description];
}

View File

@ -1,91 +0,0 @@
/*
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as commandOptions from './command-options';
import * as _ from 'lodash';
import { getCliForm } from '../utils/lazy';
const buildConfigForDeviceType = function (deviceType, advanced) {
if (advanced == null) {
advanced = false;
}
const helpers = require('../utils/helpers');
let override;
const questions = deviceType.options;
if (!advanced) {
const advancedGroup = _.find(questions, {
name: 'advanced',
isGroup: true,
});
if (advancedGroup != null) {
override = helpers.getGroupDefaults(advancedGroup);
}
}
return getCliForm().run(questions, { override });
};
const $buildConfig = function (image, deviceTypeSlug, advanced) {
if (advanced == null) {
advanced = false;
}
const Bluebird = require('bluebird');
const helpers = require('../utils/helpers');
return Bluebird.resolve(
helpers.getManifest(image, deviceTypeSlug),
).then((deviceTypeManifest) =>
buildConfigForDeviceType(deviceTypeManifest, advanced),
);
};
export const buildConfig = {
signature: 'os build-config <image> <device-type>',
description: 'build the OS config and save it to the JSON file',
help: `\
Use this command to prebuild the OS config once and skip the interactive part of \`balena os configure\`.
Example:
$ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json
$ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json\
`,
permission: 'user',
options: [
commandOptions.advancedConfig,
{
signature: 'output',
description: 'the path to the output JSON file',
alias: 'o',
required: 'the output path is required',
parameter: 'output',
},
],
action(params, options) {
return $buildConfig(
params.image,
params['device-type'],
options.advanced,
).then((answers) =>
require('fs').promises.writeFile(
options.output,
JSON.stringify(answers, null, 4),
),
);
},
};

View File

@ -1,419 +0,0 @@
/*
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as _ from 'lodash';
import { getBalenaSdk, getVisuals, getCliForm } from '../utils/lazy';
import * as dockerUtils from '../utils/docker';
const isCurrent = (commit) => commit === 'latest' || commit === 'current';
let allDeviceTypes;
const getDeviceTypes = function () {
const Bluebird = require('bluebird');
if (allDeviceTypes !== undefined) {
return Bluebird.resolve(allDeviceTypes);
}
const balena = getBalenaSdk();
return balena.models.config
.getDeviceTypes()
.then((deviceTypes) => _.sortBy(deviceTypes, 'name'))
.tap((dt) => {
allDeviceTypes = dt;
});
};
const getDeviceTypesWithSameArch = function (deviceTypeSlug) {
return getDeviceTypes().then(function (deviceTypes) {
const deviceType = _.find(deviceTypes, { slug: deviceTypeSlug });
return _(deviceTypes).filter({ arch: deviceType.arch }).map('slug').value();
});
};
const getApplicationsWithSuccessfulBuilds = function (deviceType) {
const balenaPreload = require('balena-preload');
const balena = getBalenaSdk();
return getDeviceTypesWithSameArch(deviceType).then((deviceTypes) => {
/** @type {import('balena-sdk').PineOptionsFor<import('balena-sdk').Application>} */
const options = {
$filter: {
device_type: {
$in: deviceTypes,
},
owns__release: {
$any: {
$alias: 'r',
$expr: {
r: {
status: 'success',
},
},
},
},
},
$expand: balenaPreload.applicationExpandOptions,
$select: [
'id',
'app_name',
'device_type',
'commit',
'should_track_latest_release',
],
$orderby: 'app_name asc',
};
return balena.pine.get({
resource: 'my_application',
options,
});
});
};
const selectApplication = function (deviceType) {
const visuals = getVisuals();
const { exitWithExpectedError } = require('../errors');
const applicationInfoSpinner = new visuals.Spinner(
'Downloading list of applications and releases.',
);
applicationInfoSpinner.start();
return getApplicationsWithSuccessfulBuilds(deviceType).then(function (
applications,
) {
applicationInfoSpinner.stop();
if (applications.length === 0) {
exitWithExpectedError(
`You have no apps with successful releases for a '${deviceType}' device type.`,
);
}
return getCliForm().ask({
message: 'Select an application',
type: 'list',
choices: applications.map((app) => ({
name: app.app_name,
value: app,
})),
});
});
};
const selectApplicationCommit = function (releases) {
const { exitWithExpectedError } = require('../errors');
if (releases.length === 0) {
exitWithExpectedError('This application has no successful releases.');
}
const DEFAULT_CHOICE = { name: 'current', value: 'current' };
const choices = [DEFAULT_CHOICE].concat(
releases.map((release) => ({
name: `${release.end_timestamp} - ${release.commit}`,
value: release.commit,
})),
);
return getCliForm().ask({
message: 'Select a release',
type: 'list',
default: 'current',
choices,
});
};
const offerToDisableAutomaticUpdates = function (
application,
commit,
pinDevice,
) {
const Bluebird = require('bluebird');
const balena = getBalenaSdk();
if (
isCurrent(commit) ||
!application.should_track_latest_release ||
pinDevice
) {
return Bluebird.resolve();
}
const message = `\
This application is set to automatically update all devices to the current version.
This might be unexpected behavior: with this enabled, the preloaded device will still
download and install the current release once it is online.
Do you want to disable automatic updates for this application?
Warning: To re-enable this requires direct api calls,
see https://balena.io/docs/reference/api/resources/device/#set-device-to-release
Alternatively you can pass the --pin-device-to-release flag to pin only this device to the selected release.\
`;
return getCliForm()
.ask({
message,
type: 'confirm',
})
.then(function (update) {
if (!update) {
return;
}
return balena.pine.patch({
resource: 'application',
id: application.id,
body: {
should_track_latest_release: false,
},
});
});
};
const preloadOptions = dockerUtils.appendConnectionOptions([
{
signature: 'app',
parameter: 'appId',
description: 'id of the application to preload',
alias: 'a',
},
{
signature: 'commit',
parameter: 'hash',
description: `\
The commit hash for a specific application release to preload, use "current" to specify the current
release (ignored if no appId is given). The current release is usually also the latest, but can be
manually pinned using https://github.com/balena-io-projects/staged-releases .\
`,
alias: 'c',
},
{
signature: 'splash-image',
parameter: 'splashImage.png',
description: 'path to a png image to replace the splash screen',
alias: 's',
},
{
signature: 'dont-check-arch',
boolean: true,
description:
'Disables check for matching architecture in image and application',
},
{
signature: 'pin-device-to-release',
boolean: true,
description:
'Pin the preloaded device to the preloaded release on provision',
alias: 'p',
},
{
signature: 'add-certificate',
parameter: 'certificate.crt',
description: `\
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
The file name must end with '.crt' and must not be already contained in the preloader's
/etc/ssl/certs folder.
Can be repeated to add multiple certificates.\
`,
},
]);
// Remove dockerPort `-p` alias as it conflicts with pin-device-to-release
delete _.find(preloadOptions, { signature: 'dockerPort' }).alias;
export const preload = {
signature: 'preload <image>',
description: 'preload an app on a disk image (or Edison zip archive)',
help: `\
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file
in the local disk (a zip file is only accepted for the Intel Edison device type).
After preloading, the balenaOS image file can be flashed to a device's SD card.
When the device boots, it will not need to download the application, as it was
preloaded.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)
check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
Examples:
$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png
$ balena preload balena.img\
`,
permission: 'user',
primary: true,
options: preloadOptions,
action(params, options, done) {
const balena = getBalenaSdk();
const balenaPreload = require('balena-preload');
const visuals = getVisuals();
const nodeCleanup = require('node-cleanup');
const { exitWithExpectedError } = require('../errors');
const progressBars = {};
const progressHandler = function (event) {
let progressBar = progressBars[event.name];
if (!progressBar) {
progressBar = progressBars[event.name] = new visuals.Progress(
event.name,
);
}
return progressBar.update({ percentage: event.percentage });
};
const spinners = {};
const spinnerHandler = function (event) {
let spinner = spinners[event.name];
if (!spinner) {
spinner = spinners[event.name] = new visuals.Spinner(event.name);
}
if (event.action === 'start') {
return spinner.start();
} else {
console.log();
return spinner.stop();
}
};
options.commit = isCurrent(options.commit) ? 'latest' : options.commit;
options.image = params.image;
options.appId = options.app;
delete options.app;
options.splashImage = options['splash-image'];
delete options['splash-image'];
options.dontCheckArch = options['dont-check-arch'] || false;
delete options['dont-check-arch'];
if (options.dontCheckArch && !options.appId) {
exitWithExpectedError(
'You need to specify an app id if you disable the architecture check.',
);
}
options.pinDevice = options['pin-device-to-release'] || false;
delete options['pin-device-to-release'];
let certificates;
if (Array.isArray(options['add-certificate'])) {
certificates = options['add-certificate'];
} else if (options['add-certificate'] === undefined) {
certificates = [];
} else {
certificates = [options['add-certificate']];
}
for (let certificate of certificates) {
if (!certificate.endsWith('.crt')) {
exitWithExpectedError('Certificate file name must end with ".crt"');
}
}
// Get a configured dockerode instance
return dockerUtils.getDocker(options).then(function (docker) {
const preloader = new balenaPreload.Preloader(
balena,
docker,
options.appId,
options.commit,
options.image,
options.splashImage,
options.proxy,
options.dontCheckArch,
options.pinDevice,
certificates,
);
let gotSignal = false;
nodeCleanup(function (_exitCode, signal) {
if (signal) {
gotSignal = true;
nodeCleanup.uninstall(); // don't call cleanup handler again
preloader.cleanup().then(() => {
// calling process.exit() won't inform parent process of signal
process.kill(process.pid, signal);
});
return false;
}
});
if (process.env.DEBUG) {
preloader.stderr.pipe(process.stderr);
}
preloader.on('progress', progressHandler);
preloader.on('spinner', spinnerHandler);
return new Promise(function (resolve, reject) {
preloader.on('error', reject);
return preloader
.prepare()
.then(() => {
// If no appId was provided, show a list of matching apps
if (!preloader.appId) {
return selectApplication(
preloader.config.deviceType,
).then((application) => preloader.setApplication(application));
}
})
.then(() => {
// Use the commit given as --commit or show an interactive commit selection menu
if (options.commit) {
if (isCurrent(options.commit) && preloader.application.commit) {
// handle `--commit current` (and its `--commit latest` synonym)
return 'latest';
}
const release = _.find(preloader.application.owns__release, (r) =>
r.commit.startsWith(options.commit),
);
if (!release) {
exitWithExpectedError(
'There is no release matching this commit',
);
}
return release.commit;
}
return selectApplicationCommit(preloader.application.owns__release);
})
.then(function (commit) {
if (isCurrent(commit)) {
preloader.commit = preloader.application.commit;
} else {
preloader.commit = commit;
}
// Propose to disable automatic app updates if the commit is not the current release
return offerToDisableAutomaticUpdates(
preloader.application,
commit,
options.pinDevice,
);
})
.then(() =>
// All options are ready: preload the image.
preloader.preload(),
)
.catch(balena.errors.BalenaError, exitWithExpectedError)
.then(resolve)
.catch(reject);
})
.then(done)
.finally(function () {
if (!gotSignal) {
return preloader.cleanup();
}
});
});
},
};

View File

@ -1,448 +0,0 @@
/*
Copyright 2016-2020 Balena Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { BalenaSDK } from 'balena-sdk';
import type { CommandDefinition } from 'capitano';
import { ExpectedError } from '../errors';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import {
validateApplicationName,
validateDotLocalUrl,
validateIPAddress,
} from '../utils/validation';
import { isV12 } from '../utils/version';
enum BuildTarget {
Cloud,
Device,
}
function getBuildTarget(appOrDevice: string): BuildTarget | null {
// 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 function getAppOwner(sdk: BalenaSDK, appName: string) {
const { selectFromList } = await import('../utils/patterns');
const _ = await import('lodash');
const applications = await sdk.models.application.getAll({
$expand: {
user: {
$select: ['username'],
},
},
$filter: {
$eq: [{ $tolower: { $: 'app_name' } }, appName.toLowerCase()],
},
$select: ['id'],
});
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 _.get(applications, '[0].user[0].username');
}
// 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 = _.get(app, 'user[0].username');
return {
name: `${username}/${appName}`,
extra: username,
};
});
const selected = await selectFromList(
`${entries.length} applications found with that name, please select the application you would like to push to`,
entries,
);
return selected.extra;
}
export const push: CommandDefinition<
{
// when Capitano converts a positional parameter (but not an option)
// to a number, the original value is preserved with the _raw suffix
applicationOrDevice: string;
applicationOrDevice_raw: string;
},
{
source?: string;
emulated?: boolean;
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache?: boolean;
'noparent-check'?: boolean;
'registry-secrets'?: string;
gitignore?: boolean;
nogitignore?: boolean;
nolive?: boolean;
detached?: boolean;
service?: string | string[];
system?: boolean;
env?: string | string[];
'convert-eol'?: boolean;
'noconvert-eol'?: boolean;
'multi-dockerignore'?: boolean;
}
> = {
signature: 'push <applicationOrDevice>',
primary: true,
description:
'Start a remote build on the balena cloud build servers or a local mode device',
help: stripIndent`
This command can be used to start a build on the remote balena cloud builders,
or a local mode balena device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
"git push" deployment method.
When building on a local mode device, the given source directory will be
built on the device, and the resulting containers will be run on the device.
Logs will be streamed back from the device as part of the same invocation.
The web dashboard can be used to switch a device to local mode:
https://www.balena.io/docs/learn/develop/local-mode/
Note that local mode requires a supervisor version of at least v7.21.0.
The logs from only a single service can be shown with the --service flag, and
showing only the system logs can be achieved with --system. Note that these
flags can be used together.
When pushing to a local device a live session will be started.
The project source folder is watched for filesystem events, and changes
to files and folders are automatically synchronized to the running
containers. The synchronization is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater.
${registrySecretsHelp.split('\n').join('\n\t\t')}
${dockerignoreHelp.split('\n').join('\n\t\t')}
Examples:
$ balena push myApp
$ balena push myApp --source <source directory>
$ balena push myApp -s <source directory>
$ balena push 10.0.0.1
$ balena push 10.0.0.1 --source <source directory>
$ balena push 10.0.0.1 --service my-service
$ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value
$ balena push 10.0.0.1 --nolive
$ balena push 23c73a1.local --system
$ balena push 23c73a1.local --system --service my-service
`,
options: [
{
signature: 'source',
alias: 's',
description:
'Source directory to be sent to balenaCloud or balenaOS device (default: current working dir)',
parameter: 'source',
},
{
signature: 'emulated',
alias: 'e',
description: 'Force an emulated build to occur on the remote builder',
boolean: true,
},
{
signature: 'dockerfile',
parameter: 'Dockerfile',
description:
'Alternative Dockerfile name/path, relative to the source folder',
},
{
signature: 'nocache',
alias: 'c',
description: stripIndent`
Don't use cached layers of previously built images for this project. This ensures
that the latest base image and packages are pulled. Note that build logs may still
display the message _"Pulling previous images for caching purposes" (as the cloud
builder needs previous images to compute delta updates), but the logs will not
display the "Using cache" lines for each build step of a Dockerfile.`,
boolean: true,
},
{
signature: 'noparent-check',
description:
"Disable project validation check of 'docker-compose.yml' file in parent folder",
boolean: true,
},
{
signature: 'registry-secrets',
alias: 'R',
parameter: 'secrets.yml|.json',
description: stripIndent`
Path to a local YAML or JSON file containing Docker registry passwords used to pull base images.
Note that if registry-secrets are not provided on the command line, a secrets configuration
file from the balena directory will be used (usually $HOME/.balena/secrets.yml|.json)`,
},
{
signature: 'nolive',
boolean: true,
description: stripIndent`
Don't run a live session on this push. The filesystem will not be monitored, and changes
will not be synchronized to any running containers. Note that both this flag and --detached
and required to cause the process to end once the initial build has completed.`,
},
{
signature: 'detached',
alias: 'd',
description: stripIndent`
When pushing to the cloud, this option will cause the build to start, then return execution
back to the shell, with the status and release ID (if applicable).
When pushing to a local mode device, this option will cause the command to not tail application logs when the build
has completed.`,
boolean: true,
},
{
signature: 'service',
description: stripIndent`
Reject logs not originating from this service.
This can be used in combination with --system and other --service flags.
Only valid when pushing to a local mode device.`,
parameter: 'service',
},
{
signature: 'system',
description: stripIndent`
Only show system logs. This can be used in combination with --service.
Only valid when pushing to a local mode device.`,
boolean: true,
},
{
signature: 'env',
parameter: 'env',
description: stripIndent`
When performing a push to device, run the built containers with environment
variables provided with this argument. Environment variables can be applied
to individual services by adding their service name before the argument,
separated by a colon, e.g:
--env main:MY_ENV=value
Note that if the service name cannot be found in the composition, the entire
left hand side of the = character will be treated as the variable name.
`,
},
{
signature: 'convert-eol',
alias: 'l',
description: isV12()
? 'No-op and deprecated since balena CLI v12.0.0'
: stripIndent`
On Windows only, convert line endings from CRLF (Windows format) to LF (Unix format).
Source files are not modified.`,
boolean: true,
},
...(isV12()
? [
{
signature: 'noconvert-eol',
description:
"Don't convert line endings from CRLF (Windows format) to LF (Unix format).",
boolean: true,
},
]
: []),
{
signature: 'multi-dockerignore',
alias: 'm',
description:
'Have each service use its own .dockerignore file. See "balena help push".',
boolean: true,
},
{
signature: 'nogitignore',
alias: 'G',
description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
boolean: true,
},
{
signature: 'gitignore',
alias: 'g',
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.`,
boolean: true,
},
],
async action(params, options) {
const sdk = getBalenaSdk();
const Bluebird = await import('bluebird');
const remote = await import('../utils/remote-build');
const deviceDeploy = await import('../utils/device/deploy');
const { checkLoggedIn } = await import('../utils/patterns');
const { validateProjectDirectory } = await import('../utils/compose_ts');
const { BuildError } = await import('../utils/device/errors');
const appOrDevice: string | null =
params.applicationOrDevice_raw || params.applicationOrDevice;
if (appOrDevice == null) {
throw new ExpectedError('You must specify an application or a device');
}
if (options.gitignore && options['multi-dockerignore']) {
throw new ExpectedError(
'The --gitignore and --multi-dockerignore options cannot be used together',
);
}
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,
registrySecretsPath: options['registry-secrets'],
},
);
const nogitignore = !options.gitignore;
const convertEol = isV12()
? !options['noconvert-eol']
: !!options['convert-eol'];
const buildTarget = getBuildTarget(appOrDevice);
switch (buildTarget) {
case BuildTarget.Cloud:
// Ensure that the live argument has not been passed to a cloud build
if (options.nolive != null) {
throw new ExpectedError(
'The --nolive flag is only valid when pushing to a local mode device',
);
}
if (options.service) {
throw new ExpectedError(
'The --service flag is only valid when pushing to a local mode device.',
);
}
if (options.system) {
throw new ExpectedError(
'The --system flag is only valid when pushing to a local mode device.',
);
}
if (options.env) {
throw new ExpectedError(
'The --env flag is only valid when pushing to a local mode device.',
);
}
const app = appOrDevice;
await checkLoggedIn();
const [token, baseUrl, owner] = await Promise.all([
sdk.auth.getToken(),
sdk.settings.get('balenaUrl'),
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,
sdk,
opts,
};
await remote.startRemoteBuild(args);
break;
case BuildTarget.Device:
const device = appOrDevice;
const servicesToDisplay =
options.service != null
? Array.isArray(options.service)
? options.service
: [options.service]
: undefined;
// TODO: Support passing a different port
await Bluebird.resolve(
deviceDeploy.deployToDevice({
source,
deviceHost: device,
dockerfilePath,
registrySecrets,
multiDockerignore: options['multi-dockerignore'] || false,
nocache: options.nocache || false,
nogitignore,
noParentCheck: options['noparent-check'] || false,
nolive: options.nolive || false,
detached: options.detached || false,
services: servicesToDisplay,
system: options.system || false,
env:
typeof options.env === 'string'
? [options.env]
: options.env || [],
convertEol,
}),
).catch(BuildError, (e) => {
throw new ExpectedError(e.toString());
});
break;
default:
throw new ExpectedError(
stripIndent`
Build target not recognized. Please provide either an application name or device address.
The only supported device addresses currently are IP addresses.
If you believe your build target should have been detected, and this is an error, please
create an issue.`,
);
}
},
};

View File

@ -1,98 +0,0 @@
/*
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as capitano from 'capitano';
import * as actions from './actions';
import * as events from './events';
import { promisify } from 'util';
capitano.permission('user', (done) =>
require('./utils/patterns').checkLoggedIn().then(done, done),
);
capitano.command({
signature: '*',
action(_params, _options, done) {
capitano.execute({ command: 'help' }, done);
process.exitCode = process.exitCode || 1;
},
});
capitano.globalOption({
signature: 'help',
boolean: true,
alias: 'h',
});
capitano.globalOption({
signature: 'version',
boolean: true,
alias: 'v',
});
// ---------- Help Module ----------
capitano.command(actions.help.help);
// ---------- OS Module ----------
capitano.command(actions.os.buildConfig);
// ---------- Config Module ----------
capitano.command(actions.config.read);
capitano.command(actions.config.write);
capitano.command(actions.config.inject);
capitano.command(actions.config.reconfigure);
capitano.command(actions.config.generate);
// ---------- Preload Module ----------
capitano.command(actions.preload);
// ------------ Local build and deploy -------
capitano.command(actions.build);
capitano.command(actions.deploy);
// ------------ Push/remote builds -------
capitano.command(actions.push.push);
export function run(argv: string[]) {
const cli = capitano.parse(argv.slice(2));
const runCommand = function () {
const capitanoExecuteAsync = promisify(capitano.execute);
if (cli.global?.help) {
return capitanoExecuteAsync({
command: `help ${cli.command ?? ''}`,
});
} else {
return capitanoExecuteAsync(cli);
}
};
const trackCommand = function () {
const getMatchCommandAsync = promisify(capitano.state.getMatchCommand);
return getMatchCommandAsync(cli.command).then(function (command) {
// cmdSignature is literally a string like, for example:
// "push <applicationOrDevice>"
// ("applicationOrDevice" is NOT replaced with its actual value)
// In case of failures like an nonexistent or invalid command,
// command.signature.toString() returns '*'
const cmdSignature = command.signature.toString();
return events.trackCommand(cmdSignature);
});
};
return Promise.all([trackCommand(), runCommand()]).catch(
require('./errors').handleError,
);
}

View File

@ -1,61 +0,0 @@
/**
* @license
* Copyright 2019 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Main } from '@oclif/command';
import { trackPromise } from './hooks/prerun/track';
class CustomMain extends Main {
protected _helpOverride(): boolean {
// Disable oclif's default handler for the 'version' command
if (['-v', '--version', 'version'].includes(this.argv[0])) {
return false;
} else {
return super._helpOverride();
}
}
}
import type { AppOptions } from './preparser';
/**
* oclif CLI entrypoint
*/
export async function run(command: string[], options: AppOptions) {
const runPromise = CustomMain.run(command).then(
() => {
if (!options.noFlush) {
return require('@oclif/command/flush');
}
},
(error) => {
// oclif sometimes exits with ExitError code 0 (not an error)
// (Avoid `error instanceof ExitError` here for the reasons explained
// in the CONTRIBUTING.md file regarding the `instanceof` operator.)
if (error.oclif?.exit === 0) {
return;
} else {
throw error;
}
},
);
try {
await Promise.all([trackPromise, runPromise]);
} catch (err) {
await (await import('./errors')).handleError(err);
}
}

View File

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2019 Balena Ltd.
* Copyright 2019-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -15,73 +15,138 @@
* limitations under the License.
*/
import * as packageJSON from '../package.json';
import { CliSettings } from './utils/bootstrap';
import { onceAsync, stripIndent } from './utils/lazy';
/**
* CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which
* call this function.
* Sentry.io setup
* @see https://docs.sentry.io/error-reporting/quickstart/?platform=node
*/
export const setupSentry = onceAsync(async () => {
const config = await import('./config');
const Sentry = await import('@sentry/node');
Sentry.init({
dsn: config.sentryDsn,
release: packageJSON.version,
});
Sentry.configureScope((scope) => {
scope.setExtras({
is_pkg: !!(process as any).pkg,
node_version: process.version,
platform: process.platform,
});
});
return Sentry.getCurrentHub();
});
async function checkNodeVersion() {
const validNodeVersions = packageJSON.engines.node;
if (!(await import('semver')).satisfies(process.version, validNodeVersions)) {
console.warn(stripIndent`
------------------------------------------------------------------------------
Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}".
This may cause unexpected behavior. To upgrade Node, visit:
https://nodejs.org/en/download/
------------------------------------------------------------------------------
`);
}
}
/** Setup balena-sdk options that are shared with imported packages */
function setupBalenaSdkSharedOptions(settings: CliSettings) {
const BalenaSdk = require('balena-sdk') as typeof import('balena-sdk');
BalenaSdk.setSharedOptions({
apiUrl: settings.get<string>('apiUrl'),
dataDirectory: settings.get<string>('dataDirectory'),
});
}
/**
* Addresses the console warning:
* (node:49500) MaxListenersExceededWarning: Possible EventEmitter memory
* leak detected. 11 error listeners added. Use emitter.setMaxListeners() to
* increase limit
*/
export function setMaxListeners(maxListeners: number) {
require('events').EventEmitter.defaultMaxListeners = maxListeners;
}
/** Selected CLI initialization steps */
async function init() {
if (process.env.BALENARC_NO_SENTRY) {
console.error(`WARN: disabling Sentry.io error reporting`);
} else {
await setupSentry();
}
checkNodeVersion();
const settings = new CliSettings();
// Proxy setup should be done early on, before loading balena-sdk
await (await import('./utils/proxy')).setupGlobalHttpProxy(settings);
setupBalenaSdkSharedOptions(settings);
// check for CLI updates once a day
(await import('./utils/update')).notify();
}
/** Execute the oclif parser and the CLI command. */
async function oclifRun(
command: string[],
options: import('./preparser').AppOptions,
) {
const { CustomMain } = await import('./utils/oclif-utils');
const runPromise = CustomMain.run(command).then(
() => {
if (!options.noFlush) {
return require('@oclif/command/flush');
}
},
(error) => {
// oclif sometimes exits with ExitError code 0 (not an error)
// (Avoid `error instanceof ExitError` here for the reasons explained
// in the CONTRIBUTING.md file regarding the `instanceof` operator.)
if (error.oclif?.exit === 0) {
return;
} else {
throw error;
}
},
);
const { trackPromise } = await import('./hooks/prerun/track');
await Promise.all([trackPromise, runPromise]);
}
/** CLI entrypoint. Called by the `bin/balena` and `bin/balena-dev` scripts. */
export async function run(
cliArgs = process.argv,
options: import('./preparser').AppOptions = {},
) {
// DEBUG set to falsy for negative values else is truthy
process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
process.env.DEBUG?.toLowerCase(),
)
? ''
: '1';
// The 'pkgExec' special/internal command provides a Node.js interpreter
// for use of the standalone zip package. See pkgExec function.
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
return pkgExec(cliArgs[3], cliArgs.slice(4));
}
const { globalInit } = await import('./app-common');
const { routeCliFramework } = await import('./preparser');
// globalInit() must be called very early on (before other imports) because
// it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk
// shared options, and performs node version requirement checks.
await globalInit();
await routeCliFramework(cliArgs, options);
// Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause();
}
/**
* Implements the 'pkgExec' command, used as a way to provide a Node.js
* interpreter for child_process.spawn()-like operations when the CLI is
* executing as a standalone zip package (built-in Node interpreter) and
* the system may not have a separate Node.js installation. A present use
* case is a patched version of the 'windosu' package that requires a
* Node.js interpreter to spawn a privileged child process.
*
* @param modFunc Path to a JS module that will be executed via require().
* The modFunc argument may optionally contain a function name separated
* by '::', for example '::main' in:
* 'C:\\snapshot\\balena-cli\\node_modules\\windosu\\lib\\pipe.js::main'
* in which case that function is executed in the require'd module.
* @param args Optional arguments to passed through process.argv and as
* arguments to the function specified via modFunc.
*/
async function pkgExec(modFunc: string, args: string[]) {
const [modPath, funcName] = modFunc.split('::');
let replacedModPath = modPath;
const match = modPath
.replace(/\\/g, '/')
.match(/\/snapshot\/balena-cli\/(.+)/);
if (match) {
replacedModPath = `../${match[1]}`;
}
process.argv = [process.argv[0], process.argv[1], ...args];
try {
const mod: any = await import(replacedModPath);
if (funcName) {
await mod[funcName](...args);
const { normalizeEnvVars, pkgExec } = await import('./utils/bootstrap');
normalizeEnvVars();
// The 'pkgExec' special/internal command provides a Node.js interpreter
// for use of the standalone zip package. See pkgExec function.
if (cliArgs.length > 3 && cliArgs[2] === 'pkgExec') {
return pkgExec(cliArgs[3], cliArgs.slice(4));
}
await init();
const { preparseArgs, checkDeletedCommand } = await import('./preparser');
// Look for commands that have been removed and if so, exit with a notice
checkDeletedCommand(cliArgs.slice(2));
const args = await preparseArgs(cliArgs);
await oclifRun(args, options);
} catch (err) {
console.error(`Error executing pkgExec "${modFunc}" [${args.join()}]`);
console.error(err);
await (await import('./errors')).handleError(err);
} finally {
// Windows fix: reading from stdin prevents the process from exiting
process.stdin.pause();
}
}

View File

@ -15,9 +15,7 @@ limitations under the License.
*/
import { getBalenaSdk } from '../utils/lazy';
import { awaitForToken } from './server';
export { shutdownServer } from './server';
import { LoginServer } from './server';
/**
* @module auth
@ -43,28 +41,26 @@ export { shutdownServer } from './server';
* console.log('I\'m logged in!')
* console.log("My session token is: #{sessionToken}")
*/
export const login = async () => {
export async function login({ host = '127.0.0.1', port = 0 }) {
const utils = await import('./utils');
const options = {
port: 8989,
path: '/auth',
};
const loginServer = new LoginServer();
const {
host: actualHost,
port: actualPort,
urlPath,
} = await loginServer.start({ host, port });
// Needs to be 127.0.0.1 not localhost, because the ip only is whitelisted
// from mixed content warnings (as the target of a form in the result page)
const callbackUrl = `http://127.0.0.1:${options.port}${options.path}`;
const callbackUrl = `http://${actualHost}:${actualPort}${urlPath}`;
const loginUrl = await utils.getDashboardLoginURL(callbackUrl);
console.info(`Opening web browser for URL:\n${loginUrl}`);
// Leave a bit of time for the
// server to get up and runing
setTimeout(async () => {
const open = await import('open');
open(loginUrl, { wait: false });
}, 1000);
const open = await import('open');
open(loginUrl, { wait: false });
const balena = getBalenaSdk();
const token = await awaitForToken(options);
const token = await loginServer.awaitForToken();
await balena.auth.loginWithToken(token);
loginServer.shutdown();
return token;
};
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Balena CLI - Error</title>
<title>balena CLI - Error</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Balena CLI - Success</title>
<title>balena CLI - Success</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>

View File

@ -15,6 +15,7 @@ limitations under the License.
*/
import * as bodyParser from 'body-parser';
import { EventEmitter } from 'events';
import * as express from 'express';
import type { Socket } from 'net';
import * as path from 'path';
@ -22,68 +23,55 @@ import * as path from 'path';
import * as utils from './utils';
import { ExpectedError } from '../errors';
const serverSockets: Socket[] = [];
export class LoginServer extends EventEmitter {
protected expressApp: express.Express;
protected server: import('net').Server;
protected serverSockets: Socket[] = [];
protected firstError: Error;
protected token: string;
const createServer = ({ port }: { port: number }) => {
const app = express();
app.use(
bodyParser.urlencoded({
extended: true,
}),
);
public readonly loginPath = '/auth';
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'pages'));
/**
* Start the HTTP server, listening on the given IP address and port number.
* If the port number is 0, the OS will allocate a free port number.
*/
public async start({ host = '127.0.0.1', port = 0 } = {}): Promise<{
host: string;
port: number;
urlPath: string;
}> {
this.once('error', (err: Error) => {
this.firstError = err;
});
this.on('token', (token: string) => {
this.token = token;
});
const server = app.listen(port);
server.on('connection', (socket) => serverSockets.push(socket));
const app = (this.expressApp = express());
app.use(
bodyParser.urlencoded({
extended: true,
}),
);
return { app, server };
};
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'pages'));
/**
* By design (more like a bug, but they won't admit it), a Node.js `http.server`
* instance prevents the process from exiting for up to 2 minutes (by default) if a
* client keeps a HTTP connection open, and regardless of whether `server.close()`
* was called: the `server.close(callback)` callback takes just as long to be called.
* Setting `server.timeout` to some value like 3 seconds works, but then the CLI
* process hangs for "only" 3 seconds (not good enough). Reducing the timeout to 1
* second may cause authentication failure if the laptop or CI server are slow for
* any reason. The only reliable way around it seems to be to explicitly unref the
* sockets, so the event loop stops waiting for it. See:
* https://github.com/nodejs/node/issues/2642
* https://github.com/nodejs/node-v0.x-archive/issues/9066
*/
export function shutdownServer() {
serverSockets.forEach((s) => s.unref());
serverSockets.splice(0);
}
this.server = await new Promise<import('net').Server>((resolve, reject) => {
const server = app.listen(port, host, (err: Error) => {
if (err) {
this.emit('error', err);
reject(err);
} else {
resolve(server);
}
});
server.on('connection', (socket) => this.serverSockets.push(socket));
});
/**
* @summary Await for token
* @function
* @protected
*
* @param {Object} options - options
* @param {String} options.path - callback path
* @param {Number} options.port - http port
*
* @example
* server.awaitForToken
* path: '/auth'
* port: 9001
* .then (token) ->
* console.log(token)
*/
export const awaitForToken = (options: {
path: string;
port: number;
}): Promise<string> => {
const { app, server } = createServer({ port: options.port });
return new Promise<string>((resolve, reject) => {
app.post(options.path, async (request, response) => {
server.close(); // stop listening for new connections
this.expressApp.post(this.loginPath, async (request, response) => {
this.server.close(); // stop listening for new connections
try {
const token = request.body.token?.trim();
if (!token) {
@ -93,18 +81,68 @@ export const awaitForToken = (options: {
if (!loggedIn) {
throw new ExpectedError('Invalid token');
}
this.emit('token', token);
response.status(200).render('success');
resolve(token);
} catch (error) {
this.emit('error', error);
response.status(401).render('error');
reject(new Error(error.message));
}
});
app.use((_request, response) => {
server.close(); // stop listening for new connections
this.expressApp.use((_request, response) => {
this.server.close(); // stop listening for new connections
this.emit('error', new Error('Unknown path or verb'));
response.status(404).send('Not found');
reject(new Error('Unknown path or verb'));
});
});
};
return this.getAddress();
}
public getAddress(): { host: string; port: number; urlPath: string } {
const info = this.server.address() as import('net').AddressInfo;
return {
host: info.address,
port: info.port,
urlPath: this.loginPath,
};
}
/**
* Shut the server down.
* Call this method to avoid the process hanging in some situations.
*/
public shutdown() {
// A Node.js `http.server` instance prevents the process from exiting for up to
// 2 minutes (by default) if a client keeps a HTTP connection open, and regardless
// of whether `server.close()` was called: the `server.close(callback)` callback
// takes just as long to complete. Setting `server.timeout` to some value like
// 3 seconds works, but then the CLI process hangs for "only" 3 seconds. Reducing
// the timeout to 1 second may cause authentication failure if the laptop or CI
// server are slow for any reason. The only reliable way around it seems to be to
// explicitly unref the sockets, so the event loop stops waiting for it. See:
// https://github.com/nodejs/node/issues/2642
// https://github.com/nodejs/node-v0.x-archive/issues/9066
//
this.serverSockets.forEach((s) => s.unref());
this.serverSockets.splice(0);
}
/**
* Await for the user to complete login through a web browser.
* Resolve to the authentication token string.
*
* @return Promise that resolves to the authentication token string
*/
public async awaitForToken(): Promise<string> {
if (this.firstError) {
throw this.firstError;
}
if (this.token) {
return this.token;
}
return new Promise<string>((resolve, reject) => {
this.on('error', reject);
this.on('token', resolve);
});
}
}

View File

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import * as url from 'url';
import { getBalenaSdk } from '../utils/lazy';
@ -26,7 +25,7 @@ import { getBalenaSdk } from '../utils/lazy';
*
* @param {String} callbackUrl - callback url
* @fulfil {String} - dashboard login url
* @returns {Bluebird}
* @returns {Promise}
*
* @example
* utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
@ -58,36 +57,33 @@ export const getDashboardLoginURL = (callbackUrl: string) => {
*
* @param {String} token - session token or api key
* @fulfil {Boolean} - whether the login was successful or not
* @returns {Bluebird}
* @returns {Promise}
*
* utils.loginIfTokenValid('...').then (loggedIn) ->
* if loggedIn
* console.log('Token is valid!')
*/
export const loginIfTokenValid = (token: string) => {
export const loginIfTokenValid = async (token: string): Promise<boolean> => {
if (_.isEmpty(token?.trim())) {
return Bluebird.resolve(false);
return false;
}
const balena = getBalenaSdk();
return balena.auth
.getToken()
.catchReturn(undefined)
.then((currentToken) =>
balena.auth
.loginWithToken(token)
.return(token)
.then(balena.auth.isLoggedIn)
.tap((isLoggedIn) => {
if (isLoggedIn) {
return;
}
let currentToken;
try {
currentToken = await balena.auth.getToken();
} catch {
// ignore
}
if (currentToken != null) {
return balena.auth.loginWithToken(currentToken);
} else {
return balena.auth.logout();
}
}),
);
await balena.auth.loginWithToken(token);
const isLoggedIn = await balena.auth.isLoggedIn();
if (!isLoggedIn) {
if (currentToken != null) {
await balena.auth.loginWithToken(currentToken);
} else {
await balena.auth.logout();
}
}
return isLoggedIn;
};

View File

@ -78,11 +78,25 @@ export default abstract class BalenaCommand extends Command {
* Note, currently public to allow use outside of derived commands
* (as some command implementations require this. Can be made protected
* if this changes).
*
* @throws {NotLoggedInError}
*/
public static async checkLoggedIn() {
await (await import('./utils/patterns')).checkLoggedIn();
}
/**
* Throw NotLoggedInError if not logged in when condition true.
*
* @param {boolean} doCheck - will check if true.
* @throws {NotLoggedInError}
*/
public static async checkLoggedInIf(doCheck: boolean) {
if (doCheck) {
await this.checkLoggedIn();
}
}
/**
* Read stdin contents and make available to command.
*
@ -93,6 +107,13 @@ export default abstract class BalenaCommand extends Command {
this.stdin = await (await import('get-stdin'))();
}
/**
* Get a logger instance.
*/
protected static async getLogger() {
return (await import('./utils/logger')).getLogger();
}
protected async init() {
const ctr = this.constructor as typeof BalenaCommand;

View File

@ -86,6 +86,7 @@ export default class AppCreateCmd extends Command {
application = await balena.models.application.create({
name: params.name,
deviceType,
organization: (await balena.auth.whoami())!,
});
} catch (err) {
// BalenaRequestError: Request error: Unique key constraint violated
@ -97,7 +98,7 @@ export default class AppCreateCmd extends Command {
throw err;
}
console.info(
`Application created: ${application.slug} (${application.device_type}, id ${application.id})`,
`Application created: ${application.slug} (${deviceType}, id ${application.id})`,
);
}
}

View File

@ -19,7 +19,7 @@ import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Release } from 'balena-sdk';
interface FlagsDef {
help: void;
@ -57,10 +57,21 @@ export default class AppCmd extends Command {
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppCmd);
const application = await getBalenaSdk().models.application.get(
tryAsInteger(params.name),
);
const { getApplication } = await import('../../utils/sdk');
const application = (await getApplication(getBalenaSdk(), params.name, {
$expand: {
is_for__device_type: { $select: 'slug' },
should_be_running__release: { $select: 'commit' },
},
})) as ApplicationWithDeviceType & {
should_be_running__release: [Release?];
};
// @ts-expect-error
application.device_type = application.is_for__device_type[0].slug;
// @ts-expect-error
application.commit = application.should_be_running__release[0]?.commit;
console.log(
getVisuals().table.vertical(application, [
`$${application.app_name}$`,

82
lib/commands/app/purge.ts Normal file
View File

@ -0,0 +1,82 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
}
export default class AppRestartCmd extends Command {
public static description = stripIndent`
Purge data from an application.
Purge data from all devices belonging to an application.
This will clear the application's /data directory.
`;
public static examples = ['$ balena app purge MyApp'];
public static args = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
];
public static usage = 'app purge <name>';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRestartCmd);
const balena = getBalenaSdk();
// balena.models.application.purge only accepts a numeric id
// so we must first fetch the app to get it's id, if we have been given a name
let nameOrId = tryAsInteger(params.name);
if (typeof nameOrId === 'string') {
const app = await balena.models.application.get(nameOrId);
nameOrId = app.id;
}
try {
await balena.models.application.purge(nameOrId);
} catch (e) {
if (e.message.toLowerCase().includes('no online device(s) found')) {
// application.purge throws an error if no devices are online
// ignore in this case.
} else {
throw e;
}
}
}
}

136
lib/commands/app/rename.ts Normal file
View File

@ -0,0 +1,136 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
interface FlagsDef {
help: void;
}
interface ArgsDef {
name: string;
newName?: string;
}
export default class AppRenameCmd extends Command {
public static description = stripIndent`
Rename an application.
Rename an application.
Note, if the \`newName\` parameter is omitted, it will be
prompted for interactively.
`;
public static examples = [
'$ balena app rename OldName',
'$ balena app rename OldName NewName',
];
public static args: Array<IArg<any>> = [
{
name: 'name',
description: 'application name or numeric ID',
required: true,
},
{
name: 'newName',
description: 'the new name for the application',
},
];
public static usage = 'app rename <name> [newName]';
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(AppRenameCmd);
const { ExpectedError, instanceOf } = await import('../../errors');
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
// Get app
let app;
try {
app = await getApplication(balena, params.name, {
$expand: {
application_type: {
$select: ['is_legacy'],
},
},
});
} catch (e) {
const { BalenaApplicationNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaApplicationNotFound)) {
throw new ExpectedError(`Application ${params.name} not found.`);
} else {
throw e;
}
}
// Check app supports renaming
const appType = (app.application_type as ApplicationType[])?.[0];
if (appType.is_legacy) {
throw new ExpectedError(
`Application ${params.name} is of 'legacy' type, and cannot be renamed.`,
);
}
const { validateApplicationName } = await import('../../utils/validation');
const newName =
params.newName ||
(await getCliForm().ask({
message: 'Please enter the new name for this application:',
type: 'input',
validate: validateApplicationName,
})) ||
'';
try {
await this.renameApplication(balena, app.id, newName);
} catch (e) {
// BalenaRequestError: Request error: Unique key constraint violated
if ((e.message || '').toLowerCase().includes('unique')) {
throw new ExpectedError(
`Error: application ${params.name} already exists.`,
);
}
throw e;
}
console.log(`Application ${params.name} renamed to ${newName}`);
}
async renameApplication(balena: BalenaSDK, id: number, newName: string) {
return balena.pine.patch<Application>({
resource: 'application',
id,
body: {
app_name: newName,
},
});
}
}

View File

@ -33,7 +33,7 @@ export default class AppRestartCmd extends Command {
public static description = stripIndent`
Restart an application.
Restart all devices that belongs to a certain application.
Restart all devices belonging to an application.
`;
public static examples = ['$ balena app restart MyApp'];

View File

@ -16,13 +16,12 @@
*/
import { flags } from '@oclif/command';
import type { Application } from 'balena-sdk';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { isV12 } from '../utils/version';
interface ExtendedApplication extends Application {
interface ExtendedApplication extends ApplicationWithDeviceType {
device_count?: number;
online_devices?: number;
}
@ -64,12 +63,13 @@ export default class AppsCmd extends Command {
const balena = getBalenaSdk();
// Get applications
const applications: ExtendedApplication[] = await balena.models.application.getAll(
{
$select: ['id', 'app_name', 'slug', 'device_type'],
$expand: { owns__device: { $select: 'is_online' } },
const applications = (await balena.models.application.getAll({
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
);
})) as ExtendedApplication[];
const _ = await import('lodash');
// Add extended properties
@ -78,6 +78,8 @@ export default class AppsCmd extends Command {
application.online_devices = _.sumBy(application.owns__device, (d) =>
d.is_online === true ? 1 : 0,
);
// @ts-expect-error
application.device_type = application.is_for__device_type[0].slug;
});
// Display

258
lib/commands/build.ts Normal file
View File

@ -0,0 +1,258 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import { getBalenaSdk } from '../utils/lazy';
import * as compose from '../utils/compose';
import type { Application, ApplicationType, BalenaSDK } from 'balena-sdk';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { ComposeCliFlags, ComposeOpts } from '../utils/compose-types';
import { buildProject, composeCliFlags } from '../utils/compose_ts';
import type { BuildOpts, DockerCliFlags } from '../utils/docker';
import { dockerCliFlags } from '../utils/docker';
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
arch?: string;
deviceType?: string;
application?: string;
source?: string; // Not part of command profile - source param copied here.
help: void;
}
interface ArgsDef {
source?: string;
}
export default class BuildCmd extends Command {
public static description = `\
Build a project locally.
Use this command to build an image or a complete multicontainer project with
the provided docker daemon in your development machine or balena device.
(See also the \`balena push\` command for the option of building images in the
balenaCloud build servers.)
You must provide either an application or a device-type/architecture pair.
This command will look into the given source directory (or the current working
directory if one isn't specified) for a docker-compose.yml file, and if found,
each service defined in the compose file will be built. If a compose file isn't
found, it will look for a Dockerfile[.template] file (or alternative Dockerfile
specified with the \`--dockerfile\` option), and if no dockerfile is found, it
will try to generate one.
${registrySecretsHelp}
${dockerignoreHelp}
`;
public static examples = [
'$ balena build --application myApp',
'$ balena build ./source/ --application myApp',
'$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated',
'$ balena build --docker /var/run/docker.sock --application myApp # Linux, Mac',
'$ balena build --docker //./pipe/docker_engine --application myApp # Windows',
'$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -a myApp',
];
public static args = [
{
name: 'source',
description: 'path of project source directory',
},
];
public static usage = 'build [source]';
public static flags: flags.Input<FlagsDef> = {
arch: flags.string({
description: 'the architecture to build for',
char: 'A',
}),
deviceType: flags.string({
description: 'the type of device this build is for',
char: 'd',
}),
application: flags.string({
description: 'name of the target balena application this build is for',
char: 'a',
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
};
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
BuildCmd,
);
await Command.checkLoggedInIf(!!options.application);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const sdk = getBalenaSdk();
const logger = await Command.getLogger();
logger.logDebug('Parsing input...');
// `build` accepts `source` as a parameter, but compose expects it as an option
options.source = params.source;
delete params.source;
await this.validateOptions(options, sdk);
const app = await this.getAppAndResolveArch(options);
const { docker, buildOpts, composeOpts } = await this.prepareBuild(options);
try {
await this.buildProject(docker, logger, composeOpts, {
app,
arch: options.arch!,
deviceType: options.deviceType!,
buildEmulated: options.emulated,
buildOpts,
});
} catch (err) {
logger.logError('Build failed.');
throw err;
}
logger.outputDeferredMessages();
logger.logSuccess('Build succeeded!');
}
protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) {
// Validate option combinations
if (
(opts.application == null &&
(opts.arch == null || opts.deviceType == null)) ||
(opts.application != null &&
(opts.arch != null || opts.deviceType != null))
) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
'You must specify either an application or an arch/deviceType pair to build for',
);
}
// Validate project directory
const { validateProjectDirectory } = await import('../utils/compose_ts');
const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
sdk,
{
dockerfilePath: opts.dockerfile,
noParentCheck: opts['noparent-check'] || false,
projectPath: opts.source || '.',
registrySecretsPath: opts['registry-secrets'],
},
);
opts.dockerfile = dockerfilePath;
opts['registry-secrets'] = registrySecrets;
}
protected async getAppAndResolveArch(opts: FlagsDef) {
if (opts.application) {
const { getAppWithArch } = await import('../utils/helpers');
const app = await getAppWithArch(opts.application);
opts.arch = app.arch;
opts.deviceType = app.is_for__device_type[0].slug;
return app;
}
}
protected async prepareBuild(options: FlagsDef) {
const { getDocker, generateBuildOpts } = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([
getDocker(options),
generateBuildOpts(options),
compose.generateOpts(options),
]);
return {
docker,
buildOpts,
composeOpts,
};
}
/**
* Opts must be an object with the following keys:
* app: the app this build is for (optional)
* arch: the architecture to build for
* deviceType: the device type to build for
* buildEmulated
* buildOpts: arguments to forward to docker build command
*
* @param {DockerToolbelt} docker
* @param {Logger} logger
* @param {ComposeOpts} composeOpts
* @param opts
*/
protected async buildProject(
docker: import('docker-toolbelt'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
app?: Application;
arch: string;
deviceType: string;
buildEmulated: boolean;
buildOpts: BuildOpts;
},
) {
const { loadProject } = await import('../utils/compose_ts');
const project = await loadProject(logger, composeOpts);
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
if (
appType != null &&
project.descriptors.length > 1 &&
!appType.supports_multicontainer
) {
logger.logWarn(
'Target application does not support multiple containers.\n' +
'Continuing with build, but you will not be able to deploy.',
);
}
await buildProject({
docker,
logger,
projectPath: project.path,
projectName: project.name,
composition: project.composition,
arch: opts.arch,
deviceType: opts.deviceType,
emulated: opts.buildEmulated,
buildOpts: opts.buildOpts,
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
}
}

View File

@ -0,0 +1,257 @@
/**
* @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, getCliForm, stripIndent } from '../../utils/lazy';
import type { PineDeferred } from 'balena-sdk';
interface FlagsDef {
version: string; // OS version
application?: string;
app?: string; // application alias
device?: string;
deviceApiKey?: string;
deviceType?: string;
'generate-device-api-key': boolean;
output?: string;
// Options for non-interactive configuration
network?: string;
wifiSsid?: string;
wifiKey?: string;
appUpdatePollInterval?: string;
help: void;
}
export default class ConfigGenerateCmd extends Command {
public static description = stripIndent`
Generate a config.json file.
Generate a config.json file for a device or application.
Calling this command with the exact version number of the targeted image is required.
This command is interactive by default, but you can do this automatically without interactivity
by specifying an option for each question on the command line, if you know the questions
that will be asked for the relevant device type.
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.
`;
public static examples = [
'$ balena config generate --device 7cf02a6 --version 2.12.7',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --generate-device-api-key',
'$ balena config generate --device 7cf02a6 --version 2.12.7 --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 MyApp --version 2.12.7 --output config.json',
'$ balena config generate --app MyApp --version 2.12.7 --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1',
];
public static usage = 'config generate';
public static flags: flags.Input<FlagsDef> = {
version: flags.string({
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'],
}),
device: flags.string({
description: 'device uuid',
char: 'd',
exclusive: ['application', 'app'],
}),
deviceApiKey: flags.string({
description:
'custom device key - note that this is only supported on balenaOS 2.0.3+',
char: 'k',
}),
deviceType: flags.string({
description: 'device type slug',
}),
'generate-device-api-key': flags.boolean({
description: 'generate a fresh device key for the device',
}),
output: flags.string({
description: 'path of output file',
char: 'o',
}),
// Options for non-interactive configuration
network: flags.string({
description: 'the network type to use: ethernet or wifi',
options: ['ethernet', 'wifi'],
}),
wifiSsid: flags.string({
description:
'the wifi ssid to use (used only if --network is set to wifi)',
}),
wifiKey: flags.string({
description:
'the wifi key to use (used only if --network is set to wifi)',
}),
appUpdatePollInterval: flags.string({
description:
'how frequently (in minutes) to poll for application updates',
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigGenerateCmd);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
await this.validateOptions(options);
let resourceDeviceType: string;
let application: ApplicationWithDeviceType | null = null;
let device:
| (DeviceWithDeviceType & { belongs_to__application: PineDeferred })
| null = null;
if (options.device != null) {
const { tryAsInteger } = await import('../../utils/validation');
const rawDevice = await balena.models.device.get(
tryAsInteger(options.device),
{ $expand: { is_of__device_type: { $select: 'slug' } } },
);
if (!rawDevice.belongs_to__application) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(stripIndent`
Device ${options.device} does not appear to belong to an accessible application.
Try with a different device, or use '--application' instead of '--device'.`);
}
device = rawDevice as DeviceWithDeviceType & {
belongs_to__application: PineDeferred;
};
resourceDeviceType = device.is_of__device_type[0].slug;
} else {
application = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceType;
resourceDeviceType = application.is_for__device_type[0].slug;
}
const deviceType = options.deviceType || resourceDeviceType;
const deviceManifest = await balena.models.device.getManifestBySlug(
deviceType,
);
// Check compatibility if application and deviceType provided
if (options.application && options.deviceType) {
const appDeviceManifest = await balena.models.device.getManifestBySlug(
resourceDeviceType,
);
const helpers = await import('../../utils/helpers');
if (
!helpers.areDeviceTypesCompatible(appDeviceManifest, deviceManifest)
) {
throw new balena.errors.BalenaInvalidDeviceType(
`Device type ${options.deviceType} is incompatible with application ${options.application}`,
);
}
}
// Prompt for values
// Pass params as an override: if there is any param with exactly the same name as a
// required option, that value is used (and the corresponding question is not asked)
const answers = await getCliForm().run(deviceManifest.options, {
override: options,
});
answers.version = options.version;
// Generate config
const { generateDeviceConfig, generateApplicationConfig } = await import(
'../../utils/config'
);
let config;
if (device) {
config = await generateDeviceConfig(
device,
options.deviceApiKey || options['generate-device-api-key'] || undefined,
answers,
);
} else if (application) {
answers.deviceType = deviceType;
config = await generateApplicationConfig(application, answers);
}
// Output
if (options.output != null) {
const fs = await import('fs');
await fs.promises.writeFile(options.output, JSON.stringify(config));
}
const prettyjson = await import('prettyjson');
console.log(prettyjson.render(config));
}
protected readonly missingDeviceOrAppMessage = stripIndent`
Either a device or an application must be specified.
See the help page for examples:
$ 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 async validateOptions(options: FlagsDef) {
const { ExpectedError } = await import('../../errors');
// Prefer options.application over options.app
options.application = options.application || options.app;
delete options.app;
if (options.device == null && options.application == null) {
throw new ExpectedError(this.missingDeviceOrAppMessage);
}
if (!options.application && options.deviceType) {
throw new ExpectedError(this.deviceTypeNotAllowedMessage);
}
}
}

View File

@ -0,0 +1,96 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
interface ArgsDef {
file: string;
}
export default class ConfigInjectCmd extends Command {
public static description = stripIndent`
Inject a configuration file into a device or OS image.
Inject a config.json file to the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config inject my/config.json --type raspberrypi3',
'$ balena config inject my/config.json --type raspberrypi3 --drive /dev/disk2',
];
public static args = [
{
name: 'file',
description: 'the path to the config.json file to inject',
required: true,
},
];
public static usage = 'config inject <file>';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigInjectCmd,
);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device/OS drive'));
await umountAsync(drive);
const fs = await import('fs');
const configJSON = JSON.parse(
await fs.promises.readFile(params.file, 'utf8'),
);
const config = await import('balena-config-json');
await config.write(drive, options.type, configJSON);
console.info('Done');
}
}

View File

@ -0,0 +1,78 @@
/**
* @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 { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
export default class ConfigReadCmd extends Command {
public static description = stripIndent`
Read the configuration of a device or OS image.
Read the config.json file from the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config read --type raspberrypi3',
'$ balena config read --type raspberrypi3 --drive /dev/disk2',
];
public static usage = 'config read';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReadCmd);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
const prettyjson = await import('prettyjson');
console.info(prettyjson.render(configJSON));
}
}

View File

@ -0,0 +1,90 @@
/**
* @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 { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
advanced: boolean;
help: void;
}
export default class ConfigReconfigureCmd extends Command {
public static description = stripIndent`
Interactively reconfigure a device or OS image.
Interactively reconfigure a provisioned device or OS image.
`;
public static examples = [
'$ balena config reconfigure --type raspberrypi3',
'$ balena config reconfigure --type raspberrypi3 --advanced',
'$ balena config reconfigure --type raspberrypi3 --drive /dev/disk2',
];
public static usage = 'config reconfigure';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
advanced: flags.boolean({
description: 'show advanced commands',
char: 'v',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReconfigureCmd);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const { uuid } = await config.read(drive, options.type);
await umountAsync(drive);
const configureCommand = ['os', 'configure', drive, '--device', uuid];
if (options.advanced) {
configureCommand.push('--advanced');
}
const { runCommand } = await import('../../utils/helpers');
await runCommand(configureCommand);
console.info('Done');
}
}

View File

@ -0,0 +1,106 @@
/**
* @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 { getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
type: string;
drive?: string;
help: void;
}
interface ArgsDef {
key: string;
value: string;
}
export default class ConfigWriteCmd extends Command {
public static description = stripIndent`
Write a key-value pair to configuration of a device or OS image.
Write a key-value pair to the config.json file on the mounted filesystem,
e.g. the SD card of a provisioned device or balenaOS image.
`;
public static examples = [
'$ balena config write --type raspberrypi3 username johndoe',
'$ balena config write --type raspberrypi3 --drive /dev/disk2 username johndoe',
'$ balena config write --type raspberrypi3 files.network/settings "..."',
];
public static args = [
{
name: 'key',
description: 'the key of the config parameter to write',
required: true,
},
{
name: 'value',
description: 'the value of the config parameter to write',
required: true,
},
];
public static usage = 'config write <key> <value>';
public static flags: flags.Input<FlagsDef> = {
type: flags.string({
description:
'device type (Check available types with `balena devices supported`)',
char: 't',
required: true,
}),
drive: flags.string({
description: 'device filesystem or OS image location',
char: 'd',
}),
help: cf.help,
};
public static authenticated = true;
public static root = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
ConfigWriteCmd,
);
const { promisify } = await import('util');
const umountAsync = promisify((await import('umount')).umount);
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));
await umountAsync(drive);
const config = await import('balena-config-json');
const configJSON = await config.read(drive, options.type);
console.info(`Setting ${params.key} to ${params.value}`);
const _ = await import('lodash');
_.set(configJSON, params.key, params.value);
await umountAsync(drive);
await config.write(drive, options.type, configJSON);
console.info('Done');
}
}

352
lib/commands/deploy.ts Normal file
View File

@ -0,0 +1,352 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { ImageDescriptor } from 'resin-compose-parse';
import Command from '../command';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import * as compose from '../utils/compose';
import type {
BuiltImage,
ComposeCliFlags,
ComposeOpts,
} from '../utils/compose-types';
import type { DockerCliFlags } from '../utils/docker';
import {
buildProject,
composeCliFlags,
isBuildConfig,
} from '../utils/compose_ts';
import { dockerCliFlags } from '../utils/docker';
import type { Application, ApplicationType, DeviceType } from 'balena-sdk';
interface ApplicationWithArch extends Application {
arch: string;
}
interface FlagsDef extends ComposeCliFlags, DockerCliFlags {
source?: string;
build: boolean;
nologupload: boolean;
help: void;
}
interface ArgsDef {
appName: string;
image?: string;
}
export default class DeployCmd extends Command {
public static description = `\
Deploy a single image or a multicontainer project to a balena application.
Usage: \`deploy <appName> ([image] | --build [--source build-dir])\`
Use this command to deploy an image or a complete multicontainer project to an
application, optionally building it first. The source images are searched for
(and optionally built) using the docker daemon in your development machine or
balena device. (See also the \`balena push\` command for the option of building
the image in the balenaCloud build servers.)
Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a docker-compose.yml file. If one is
found, this command will deploy each service defined in the compose file,
building it first if an image for it doesn't exist. If a compose file isn't
found, the command will look for a Dockerfile[.template] file (or alternative
Dockerfile specified with the \`-f\` option), and if yet that isn't found, it
will try to generate one.
To deploy to an app on which you're a collaborator, use
\`balena deploy <appOwnerUsername>/<appName>\`.
${registrySecretsHelp}
${dockerignoreHelp}
`;
public static examples = [
'$ balena deploy myApp',
'$ balena deploy myApp --build --source myBuildDir/',
'$ balena deploy myApp myApp/myImage',
];
public static args = [
{
name: 'appName',
description: 'the name of the application to deploy to',
required: true,
},
{
name: 'image',
description: 'the image to deploy',
},
];
public static usage = 'deploy <appName> [image]';
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description:
'specify an alternate source directory; default is the working directory',
char: 's',
}),
build: flags.boolean({
description: 'force a rebuild before deploy',
char: 'b',
}),
nologupload: flags.boolean({
description:
"don't upload build logs to the dashboard with image (if building)",
}),
...composeCliFlags,
...dockerCliFlags,
// NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeployCmd,
);
// compositions with many services trigger misleading warnings
// @ts-ignore editing property that isn't typed but does exist
(await import('events')).defaultMaxListeners = 1000;
const logger = await Command.getLogger();
logger.logDebug('Parsing input...');
const { appName, image } = params;
if (image != null && options.build) {
throw new ExpectedError(
'Build option is not applicable when specifying an image',
);
}
const sdk = getBalenaSdk();
const { getRegistrySecrets, validateProjectDirectory } = await import(
'../utils/compose_ts'
);
if (image) {
options['registry-secrets'] = await getRegistrySecrets(
sdk,
options['registry-secrets'],
);
} else {
const {
dockerfilePath,
registrySecrets,
} = await validateProjectDirectory(sdk, {
dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false,
projectPath: options.source || '.',
registrySecretsPath: options['registry-secrets'],
});
options.dockerfile = dockerfilePath;
options['registry-secrets'] = registrySecrets;
}
const helpers = await import('../utils/helpers');
const app = await helpers.getAppWithArch(appName);
const dockerUtils = await import('../utils/docker');
const [docker, buildOpts, composeOpts] = await Promise.all([
dockerUtils.getDocker(options),
dockerUtils.generateBuildOpts(options),
compose.generateOpts(options),
]);
await this.deployProject(docker, logger, composeOpts, {
app,
appName, // may be prefixed by 'owner/', unlike app.app_name
image,
shouldPerformBuild: !!options.build,
shouldUploadLogs: !options.nologupload,
buildEmulated: !!options.emulated,
buildOpts,
});
}
async deployProject(
docker: import('docker-toolbelt'),
logger: import('../utils/logger'),
composeOpts: ComposeOpts,
opts: {
app: ApplicationWithArch; // the application instance to deploy to
appName: string;
image?: string;
dockerfilePath?: string; // alternative Dockerfile
shouldPerformBuild: boolean;
shouldUploadLogs: boolean;
buildEmulated: boolean;
buildOpts: any; // arguments to forward to docker build command
},
) {
const _ = await import('lodash');
const doodles = await import('resin-doodles');
const sdk = getBalenaSdk();
const { deployProject: $deployProject, loadProject } = await import(
'../utils/compose_ts'
);
const appType = (opts.app?.application_type as ApplicationType[])?.[0];
try {
const project = await loadProject(logger, composeOpts, opts.image);
if (project.descriptors.length > 1 && !appType?.supports_multicontainer) {
throw new ExpectedError(
'Target application does not support multiple containers. Aborting!',
);
}
// find which services use images that already exist locally
let servicesToSkip: string[] = await Promise.all(
project.descriptors.map(async function (d: ImageDescriptor) {
// unconditionally build (or pull) if explicitly requested
if (opts.shouldPerformBuild) {
return '';
}
try {
await docker
.getImage((isBuildConfig(d.image) ? d.image.tag : d.image) || '')
.inspect();
return d.serviceName;
} catch {
// Ignore
return '';
}
}),
);
servicesToSkip = servicesToSkip.filter((d) => !!d);
// multibuild takes in a composition and always attempts to
// build or pull all services. we workaround that here by
// passing a modified composition.
const compositionToBuild = _.cloneDeep(project.composition);
compositionToBuild.services = _.omit(
compositionToBuild.services,
servicesToSkip,
);
let builtImagesByService: Dictionary<BuiltImage> = {};
if (_.size(compositionToBuild.services) === 0) {
logger.logInfo(
'Everything is up to date (use --build to force a rebuild)',
);
} else {
const builtImages = await buildProject({
docker,
logger,
projectPath: project.path,
projectName: project.name,
composition: compositionToBuild,
arch: opts.app.arch,
deviceType: (opts.app?.is_for__device_type as DeviceType[])?.[0].slug,
emulated: opts.buildEmulated,
buildOpts: opts.buildOpts,
inlineLogs: composeOpts.inlineLogs,
convertEol: composeOpts.convertEol,
dockerfilePath: composeOpts.dockerfilePath,
nogitignore: composeOpts.nogitignore,
multiDockerignore: composeOpts.multiDockerignore,
});
builtImagesByService = _.keyBy(builtImages, 'serviceName');
}
const images: BuiltImage[] = project.descriptors.map(
(d) =>
builtImagesByService[d.serviceName] ?? {
serviceName: d.serviceName,
name: (isBuildConfig(d.image) ? d.image.tag : d.image) || '',
logs: 'Build skipped; image for service already exists.',
props: {},
},
);
let release;
if (appType?.is_legacy) {
const { deployLegacy } = require('../utils/deploy-legacy');
const msg = getChalk().yellow(
'Target application requires legacy deploy method.',
);
logger.logWarn(msg);
const [token, username, url, options] = await Promise.all([
sdk.auth.getToken(),
sdk.auth.whoami(),
sdk.settings.get('balenaUrl'),
{
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
appName: opts.appName,
imageName: images[0].name,
buildLogs: images[0].logs,
shouldUploadLogs: opts.shouldUploadLogs,
},
]);
const releaseId = await deployLegacy(
docker,
logger,
token,
username,
url,
options,
);
release = await sdk.models.release.get(releaseId, {
$select: ['commit'],
});
} else {
const [userId, auth, apiEndpoint] = await Promise.all([
sdk.auth.getUserId(),
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
]);
release = await $deployProject(
docker,
logger,
project.composition,
images,
opts.app.id,
userId,
`Bearer ${auth}`,
apiEndpoint,
!opts.shouldUploadLogs,
);
}
logger.outputDeferredMessages();
logger.logSuccess('Deploy succeeded!');
logger.logSuccess(`Release: ${release.commit}`);
console.log();
console.log(doodles.getDoodle()); // Show charlie
console.log();
} catch (err) {
logger.logError('Deploy failed');
throw err;
}
}
}

View File

@ -22,13 +22,23 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Application, Device } from 'balena-sdk';
import type { Application, Release } from 'balena-sdk';
interface ExtendedDevice extends Device {
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
application_name?: string;
device_type?: string;
commit?: string;
last_seen?: string;
memory_usage_mb: number | null;
memory_total_mb: number | null;
memory_usage_percent?: number;
storage_usage_mb: number | null;
storage_total_mb: number | null;
storage_usage_percent?: number;
cpu_temp_c: number | null;
cpu_usage_percent: number | null;
undervoltage_detected?: boolean;
}
interface FlagsDef {
@ -70,25 +80,32 @@ export default class DeviceCmd extends Command {
const balena = getBalenaSdk();
const device: ExtendedDevice = await balena.models.device.get(params.uuid, {
const device = (await balena.models.device.get(params.uuid, {
$select: [
'device_name',
'id',
'device_type',
'overall_status',
'is_online',
'ip_address',
'mac_address',
'last_connectivity_event',
'uuid',
'is_on__commit',
'supervisor_version',
'is_web_accessible',
'note',
'os_version',
'memory_usage',
'memory_total',
'storage_block_device',
'storage_usage',
'storage_total',
'cpu_usage',
'cpu_temp',
'cpu_id',
'is_undervolted',
],
...expandForAppName,
});
})) as ExtendedDevice;
device.status = device.overall_status;
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
@ -98,8 +115,50 @@ export default class DeviceCmd extends Command {
? belongsToApplication[0].app_name
: 'N/a';
device.commit = device.is_on__commit;
device.last_seen = device.last_connectivity_event;
device.device_type = device.is_of__device_type[0].slug;
const isRunningRelease = device.is_running__release as Release[];
device.commit = isRunningRelease?.[0] ? isRunningRelease[0].commit : 'N/a';
device.last_seen = device.last_connectivity_event ?? undefined;
// Memory/Storage are really MiB
// Consider changing headings to MiB once we can do lowercase
device.memory_usage_mb = device.memory_usage;
device.memory_total_mb = device.memory_total;
device.storage_usage_mb = device.storage_usage;
device.storage_total_mb = device.storage_total;
device.cpu_temp_c = device.cpu_temp;
device.cpu_usage_percent = device.cpu_usage;
// Only show undervoltage status if true
// API sends false even for devices which are not detecting this.
if (device.is_undervolted) {
device.undervoltage_detected = device.is_undervolted;
}
if (
device.memory_usage != null &&
device.memory_total != null &&
device.memory_total !== 0
) {
device.memory_usage_percent = Math.round(
(device.memory_usage / device.memory_total) * 100,
);
}
if (
device.storage_usage != null &&
device.storage_total != null &&
device.storage_total !== 0
) {
device.storage_usage_percent = Math.round(
(device.storage_usage / device.storage_total) * 100,
);
}
console.log(
getVisuals().table.vertical(device, [
@ -119,6 +178,17 @@ export default class DeviceCmd extends Command {
'note',
'os_version',
'dashboard_url',
'cpu_usage_percent',
'cpu_temp_c',
'cpu_id',
'memory_usage_mb',
'memory_total_mb',
'memory_usage_percent',
'storage_block_device',
'storage_usage_mb',
'storage_total_mb',
'storage_usage_percent',
'undervoltage_detected',
]),
);
}

View File

@ -79,26 +79,34 @@ export default class DeviceInitCmd extends Command {
const { flags: options } = this.parse<FlagsDef, {}>(DeviceInitCmd);
// Imports
const { promisify } = await import('bluebird');
const { promisify } = await import('util');
const rimraf = promisify(await import('rimraf'));
const tmp = await import('tmp');
const tmpNameAsync = promisify(tmp.tmpName);
tmp.setGracefulCleanup();
const balena = getBalenaSdk();
const { downloadOSImage } = await import('../../utils/cloud');
const Logger = await import('../../utils/logger');
const { getApplication } = await import('../../utils/sdk');
const logger = Logger.getLogger();
const logger = await Command.getLogger();
const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
// Get application and
const application = await balena.models.application.get(
const application = (await getApplication(
balena,
options['application'] ||
(await (await import('../../utils/patterns')).selectApplication()),
);
{
$expand: {
is_for__device_type: {
$select: 'slug',
},
},
},
)) as ApplicationWithDeviceType;
// Register new device
const deviceUuid = balena.models.device.generateUniqueKey();
@ -111,13 +119,14 @@ export default class DeviceInitCmd extends Command {
try {
logger.logDebug(`Downloading OS image...`);
const osVersion = options['os-version'] || 'default';
await downloadOSImage(application.device_type, tmpPath, osVersion);
const deviceType = application.is_for__device_type[0].slug;
await downloadOSImage(deviceType, tmpPath, osVersion);
logger.logDebug(`Configuring OS image...`);
await this.configureOsImage(tmpPath, device.uuid, options);
logger.logDebug(`Writing OS image...`);
await this.writeOsImage(tmpPath, application.device_type, options);
await this.writeOsImage(tmpPath, deviceType, options);
} catch (e) {
// Remove device in failed cases
try {

View File

@ -17,7 +17,7 @@
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import type { Application, Device } from 'balena-sdk';
import type { Application, BalenaSDK } from 'balena-sdk';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
@ -25,7 +25,7 @@ import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import { ExpectedError } from '../../errors';
interface ExtendedDevice extends Device {
interface ExtendedDevice extends DeviceWithDeviceType {
application_name?: string;
}
@ -59,7 +59,6 @@ export default class DeviceMoveCmd extends Command {
name: 'uuid',
description:
'comma-separated list (no blank spaces) of device UUIDs to be moved',
parse: (dev) => tryAsInteger(dev),
required: true,
},
];
@ -81,16 +80,25 @@ export default class DeviceMoveCmd extends Command {
const balena = getBalenaSdk();
// Consolidate application options
options.application = options.application || options.app;
delete options.app;
const devices: ExtendedDevice[] = await Promise.all(
params.uuid
.split(',')
.map((uuid) => balena.models.device.get(uuid, expandForAppName)),
// Parse ids string into array of correct types
const deviceIds: Array<string | number> = params.uuid
.split(',')
.map((id) => tryAsInteger(id));
// Get devices
const devices = await Promise.all(
deviceIds.map(
(uuid) =>
balena.models.device.get(uuid, expandForAppName) as Promise<
ExtendedDevice
>,
),
);
// Map application name for each device
for (const device of devices) {
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]
@ -99,51 +107,12 @@ export default class DeviceMoveCmd extends Command {
}
// Get destination application
let application;
if (options.application) {
application = options.application;
} else {
const [deviceDeviceTypes, deviceTypes] = await Promise.all([
Promise.all(
devices.map((device) =>
balena.models.device.getManifestBySlug(device.device_type),
),
),
balena.models.config.getDeviceTypes(),
]);
const application =
options.application ||
(await this.interactivelySelectApplication(balena, devices));
const compatibleDeviceTypes = deviceTypes.filter((dt) =>
deviceDeviceTypes.every(
(deviceDeviceType) =>
balena.models.os.isArchitectureCompatibleWith(
deviceDeviceType.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceDeviceType.isDependent &&
dt.state !== 'DISCONTINUED',
),
);
const patterns = await import('../../utils/patterns');
try {
application = await patterns.selectApplication(
(app: Application) =>
compatibleDeviceTypes.some((dt) => dt.slug === app.device_type) &&
// @ts-ignore using the extended device object prop
devices.some((device) => device.application_name !== app.app_name),
true,
);
} catch (err) {
if (deviceDeviceTypes.length) {
throw new ExpectedError(
`${err.message}\nDo all devices have a compatible architecture?`,
);
}
throw err;
}
}
for (const uuid of params.uuid.split(',')) {
// Move each device
for (const uuid of deviceIds) {
try {
await balena.models.device.move(uuid, tryAsInteger(application));
console.info(`${uuid} was moved to ${application}`);
@ -153,4 +122,53 @@ export default class DeviceMoveCmd extends Command {
}
}
}
async interactivelySelectApplication(
balena: BalenaSDK,
devices: ExtendedDevice[],
) {
const [deviceDeviceTypes, deviceTypes] = await Promise.all([
Promise.all(
devices.map((device) =>
balena.models.device.getManifestBySlug(
device.is_of__device_type[0].slug,
),
),
),
balena.models.config.getDeviceTypes(),
]);
const compatibleDeviceTypes = deviceTypes.filter((dt) =>
deviceDeviceTypes.every(
(deviceDeviceType) =>
balena.models.os.isArchitectureCompatibleWith(
deviceDeviceType.arch,
dt.arch,
) &&
!!dt.isDependent === !!deviceDeviceType.isDependent &&
dt.state !== 'DISCONTINUED',
),
);
const patterns = await import('../../utils/patterns');
try {
const application = await patterns.selectApplication(
(app) =>
compatibleDeviceTypes.some(
(dt) => dt.slug === app.is_for__device_type[0].slug,
) &&
// @ts-ignore using the extended device object prop
devices.some((device) => device.application_name !== app.app_name),
true,
);
return application;
} catch (err) {
if (deviceDeviceTypes.length) {
throw new ExpectedError(
`${err.message}\nDo all devices have a compatible architecture?`,
);
}
throw err;
}
}
}

View File

@ -81,12 +81,17 @@ export default class DeviceOsUpdateCmd extends Command {
// Get device info
const {
uuid,
device_type,
is_of__device_type,
os_version,
os_variant,
} = await sdk.models.device.get(params.uuid, {
$select: ['uuid', 'device_type', 'os_version', 'os_variant'],
});
} = (await sdk.models.device.get(params.uuid, {
$select: ['uuid', 'os_version', 'os_variant'],
$expand: {
is_of__device_type: {
$select: 'slug',
},
},
})) as DeviceWithDeviceType;
// Get current device OS version
const currentOsVersion = sdk.models.device.getOsVersion({
@ -101,7 +106,7 @@ export default class DeviceOsUpdateCmd extends Command {
// Get supported OS update versions
const hupVersionInfo = await sdk.models.os.getSupportedOsUpdateVersions(
device_type,
is_of__device_type[0].slug,
currentOsVersion,
);
if (hupVersionInfo.versions.length === 0) {

View File

@ -0,0 +1,80 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
interface FlagsDef {
help: void;
}
interface ArgsDef {
uuid: string;
}
export default class DevicePurgeCmd extends Command {
public static description = stripIndent`
Purge application data from a device.
Purge application data from a device.
This will clear the application's /data directory.
Multiple devices may be specified with a comma-separated list
of values (no spaces).
`;
public static examples = [
'$ balena device purge 23c73a1',
'$ balena device purge 55d43b3,23c73a1',
];
public static usage = 'device purge <uuid>';
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'comma-separated list (no blank spaces) of device UUIDs',
required: true,
},
];
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(DevicePurgeCmd);
const { tryAsInteger } = await import('../../utils/validation');
const balena = getBalenaSdk();
const ux = getCliUx();
const deviceIds = params.uuid.split(',').map((id) => {
return tryAsInteger(id);
});
for (const deviceId of deviceIds) {
ux.action.start(`Purging data from device ${deviceId}`);
await balena.models.device.purge(deviceId);
ux.action.stop();
}
}
}

View File

@ -20,7 +20,6 @@ import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
interface FlagsDef {
uuid?: string;
@ -46,7 +45,6 @@ export default class DeviceRegisterCmd extends Command {
{
name: 'application',
description: 'the name or id of application to register device with',
parse: (app) => tryAsInteger(app),
required: true,
},
];
@ -68,9 +66,11 @@ export default class DeviceRegisterCmd extends Command {
DeviceRegisterCmd,
);
const { getApplication } = await import('../../utils/sdk');
const balena = getBalenaSdk();
const application = await balena.models.application.get(params.application);
const application = await getApplication(balena, params.application);
const uuid = options.uuid ?? balena.models.device.generateUniqueKey();
console.info(`Registering to ${application.app_name}: ${uuid}`);

View File

@ -0,0 +1,197 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import type { IArg } from '@oclif/parser/lib/args';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, getCliUx, stripIndent } from '../../utils/lazy';
import type {
BalenaSDK,
DeviceWithServiceDetails,
CurrentServiceWithCommit,
} from 'balena-sdk';
interface FlagsDef {
help: void;
service?: string;
}
interface ArgsDef {
uuid: string;
}
export default class DeviceRestartCmd extends Command {
public static description = stripIndent`
Restart containers on a device.
Restart containers on a device.
If the --service flag is provided, then only those services' containers
will be restarted, otherwise all containers on the device will be restarted.
Multiple devices and services may be specified with a comma-separated list
of values (no spaces).
Note this does not reboot the device, to do so use instead \`balena device reboot\`.
`;
public static examples = [
'$ balena device restart 23c73a1',
'$ balena device restart 55d43b3,23c73a1',
'$ balena device restart 23c73a1 --service myService',
'$ balena device restart 23c73a1 -s myService1,myService2',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description:
'comma-separated list (no blank spaces) of device UUIDs to restart',
required: true,
},
];
public static usage = 'device restart <uuid>';
public static flags: flags.Input<FlagsDef> = {
service: flags.string({
description:
'comma-separated list (no blank spaces) of service names to restart',
char: 's',
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
DeviceRestartCmd,
);
const { tryAsInteger } = await import('../../utils/validation');
const balena = getBalenaSdk();
const ux = getCliUx();
const deviceIds = params.uuid.split(',').map((id) => {
return tryAsInteger(id);
});
const serviceNames = options.service?.split(',');
// Iterate sequentially through deviceIds.
// We may later want to add a batching feature,
// so that n devices are processed in parallel
for (const deviceId of deviceIds) {
ux.action.start(`Restarting services on device ${deviceId}`);
if (serviceNames) {
await this.restartServices(balena, deviceId, serviceNames);
} else {
await this.restartAllServices(balena, deviceId);
}
ux.action.stop();
}
}
async restartServices(
balena: BalenaSDK,
deviceId: number | string,
serviceNames: string[],
) {
const { ExpectedError, instanceOf } = await import('../../errors');
const { getExpandedProp } = await import('../../utils/pine');
// Get device
let device: DeviceWithServiceDetails<CurrentServiceWithCommit>;
try {
device = await balena.models.device.getWithServiceDetails(deviceId, {
$expand: {
is_running__release: { $select: 'commit' },
},
});
} catch (e) {
const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaDeviceNotFound)) {
throw new ExpectedError(`Device ${deviceId} not found.`);
} else {
throw e;
}
}
const activeRelease = getExpandedProp(device.is_running__release, 'commit');
// Check specified services exist on this device before restarting anything
serviceNames.forEach((service) => {
if (!device.current_services[service]) {
throw new ExpectedError(
`Service ${service} not found on device ${deviceId}.`,
);
}
});
// Restart services
const restartPromises: Array<Promise<void>> = [];
for (const serviceName of serviceNames) {
const service = device.current_services[serviceName];
// Each service is an array of `CurrentServiceWithCommit`
// because when service is updating, it will actually hold 2 services
// Target commit matching `device.is_running__release`
const serviceContainer = service.find((s) => {
return s.commit === activeRelease;
});
if (serviceContainer) {
restartPromises.push(
balena.models.device.restartService(
deviceId,
serviceContainer.image_id,
),
);
}
}
try {
await Promise.all(restartPromises);
} catch (e) {
if (e.message.toLowerCase().includes('no online device')) {
throw new ExpectedError(`Device ${deviceId} is not online.`);
} else {
throw e;
}
}
}
async restartAllServices(balena: BalenaSDK, deviceId: number | string) {
// Note: device.restartApplication throws `BalenaDeviceNotFound: Device not found` if device not online.
// Need to use device.get first to distinguish between non-existant and offline devices.
// Remove this workaround when SDK issue resolved: https://github.com/balena-io/balena-sdk/issues/649
const { instanceOf, ExpectedError } = await import('../../errors');
try {
const device = await balena.models.device.get(deviceId);
if (!device.is_online) {
throw new ExpectedError(`Device ${deviceId} is not online.`);
}
} catch (e) {
const { BalenaDeviceNotFound } = await import('balena-errors');
if (instanceOf(e, BalenaDeviceNotFound)) {
throw new ExpectedError(`Device ${deviceId} not found.`);
} else {
throw e;
}
}
await balena.models.device.restartApplication(deviceId);
}
}

View File

@ -33,28 +33,29 @@ interface ArgsDef {
export default class DeviceRmCmd extends Command {
public static description = stripIndent`
Remove a device.
Remove one or more devices.
Remove a device from balena.
Remove one or more devices from balena.
Note this command asks for confirmation interactively.
You can avoid this by passing the \`--yes\` option.
`;
public static examples = [
'$ balena device rm 7cf02a6',
'$ balena device rm 7cf02a6,dc39e52',
'$ balena device rm 7cf02a6 --yes',
];
public static args: Array<IArg<any>> = [
{
name: 'uuid',
description: 'the uuid of the device to remove',
parse: (dev) => tryAsInteger(dev),
description:
'comma-separated list (no blank spaces) of device UUIDs to be removed',
required: true,
},
];
public static usage = 'device rm <uuid>';
public static usage = 'device rm <uuid(s)>';
public static flags: flags.Input<FlagsDef> = {
yes: cf.yes,
@ -72,12 +73,23 @@ export default class DeviceRmCmd extends Command {
const patterns = await import('../../utils/patterns');
// Confirm
const uuids = params.uuid.split(',');
await patterns.confirm(
options.yes,
'Are you sure you want to delete the device?',
uuids.length > 1
? `Are you sure you want to delete ${uuids.length} devices?`
: `Are you sure you want to delete device ${uuids[0]} ?`,
);
// Remove
await balena.models.device.remove(params.uuid);
for (const uuid of uuids) {
try {
await balena.models.device.remove(tryAsInteger(uuid));
} catch (err) {
console.info(`${err.message}, uuid: ${uuid}`);
process.exitCode = 1;
continue;
}
}
}
}

View File

@ -21,17 +21,19 @@ import * as cf from '../../utils/common-flags';
import { expandForAppName } from '../../utils/helpers';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { tryAsInteger } from '../../utils/validation';
import type { Device, Application } from 'balena-sdk';
import type { Application } from 'balena-sdk';
interface ExtendedDevice extends Device {
interface ExtendedDevice extends DeviceWithDeviceType {
dashboard_url?: string;
application_name?: string;
application_name?: string | null;
device_type?: string | null;
}
interface FlagsDef {
application?: string;
app?: string;
help: void;
json: boolean;
}
export default class DevicesCmd extends Command {
@ -41,6 +43,12 @@ export default class DevicesCmd extends Command {
list all devices that belong to you.
You can filter the devices by application by using the \`--application\` option.
The --json option is recommended when scripting the output of this command,
because field names are less likely to change in JSON format and because it
better represents data types like arrays, empty strings and null values.
The 'jq' utility may be helpful for querying JSON fields in shell scripts
(https://stedolan.github.io/jq/manual/).
`;
public static examples = [
'$ balena devices',
@ -55,6 +63,10 @@ export default class DevicesCmd extends Command {
application: cf.application,
app: cf.app,
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
};
public static primary = true;
@ -70,42 +82,59 @@ export default class DevicesCmd extends Command {
options.application = options.application || options.app;
delete options.app;
let devices: ExtendedDevice[];
let devices;
if (options.application != null) {
devices = await balena.models.device.getAllByApplication(
devices = (await balena.models.device.getAllByApplication(
tryAsInteger(options.application),
expandForAppName,
);
)) as ExtendedDevice[];
} else {
devices = await balena.models.device.getAll(expandForAppName);
devices = (await balena.models.device.getAll(
expandForAppName,
)) as ExtendedDevice[];
}
devices = devices.map(function (device) {
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid);
const belongsToApplication = device.belongs_to__application as Application[];
device.application_name = belongsToApplication?.[0]
? belongsToApplication[0].app_name
: 'N/a';
device.application_name = belongsToApplication?.[0]?.app_name || null;
device.uuid = device.uuid.slice(0, 7);
device.device_type = device.is_of__device_type?.[0]?.slug || null;
return device;
});
console.log(
getVisuals().table.horizontal(devices, [
'id',
'uuid',
'device_name',
'device_type',
'application_name',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
]),
);
const fields = [
'id',
'uuid',
'device_name',
'device_type',
'application_name',
'status',
'is_online',
'supervisor_version',
'os_version',
'dashboard_url',
];
const _ = await import('lodash');
if (options.json) {
console.log(
JSON.stringify(
devices.map((device) => _.pick(device, fields)),
null,
4,
),
);
} else {
console.log(
getVisuals().table.horizontal(
devices.map((dev) => _.mapValues(dev, (val) => val ?? 'N/a')),
fields,
),
);
}
}
}

View File

@ -76,9 +76,9 @@ export default class DevicesSupportedCmd extends Command {
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(DevicesSupportedCmd);
let deviceTypes: Array<Partial<SDK.DeviceType>> = await getBalenaSdk()
.models.config.getDeviceTypes()
.map((d) => {
const dts = await getBalenaSdk().models.config.getDeviceTypes();
let deviceTypes: Array<Partial<SDK.DeviceTypeJson.DeviceType>> = dts.map(
(d) => {
if (d.aliases && d.aliases.length) {
// remove aliases that are equal to the slug
d.aliases = d.aliases.filter((alias: string) => alias !== d.slug);
@ -91,7 +91,8 @@ export default class DevicesSupportedCmd extends Command {
d.aliases = [];
}
return d;
});
},
);
if (!options.discontinued) {
deviceTypes = deviceTypes.filter((dt) => dt.state !== 'DISCONTINUED');
}
@ -100,7 +101,7 @@ export default class DevicesSupportedCmd extends Command {
: ['slug', 'aliases', 'arch', 'name'];
deviceTypes = _.sortBy(
deviceTypes.map((d) => {
const picked = _.pick<Partial<SDK.DeviceType>>(d, fields);
const picked = _.pick(d, fields);
// 'BETA' renamed to 'NEW'
picked.state = picked.state === 'BETA' ? 'NEW' : picked.state;
return picked;

View File

@ -22,7 +22,6 @@ import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
interface FlagsDef {
application?: string; // application name
@ -39,17 +38,18 @@ interface ArgsDef {
export default class EnvAddCmd extends Command {
public static description = stripIndent`
Add an environment or config variable to an application, device or service.
Add env or config variable to application(s), device(s) or service(s).
Add an environment or config variable to an application, device or service,
as selected by the respective command-line options. Either the --application
or the --device option must be provided, and either may be be used alongside
the --service option to define a service-specific variable. (A service is an
application container in a "microservices" application.) When the --service
option is used in conjunction with the --device option, the service variable
applies to the selected device only. Otherwise, it applies to all devices of
the selected application (i.e., the application's fleet). If the --service
option is omitted, the variable applies to all services.
Add an environment or config variable to one or more applications, devices
or services, as selected by the respective command-line options. Either the
--application or the --device option must be provided, and either may be be
used alongside the --service option to define a service-specific variable.
(A service is an application container in a "microservices" application.)
When the --service option is used in conjunction with the --device option,
the service variable applies to the selected device only. Otherwise, it
applies to all devices of the selected application (i.e., the application's
fleet). If the --service option is omitted, the variable applies to all
services.
If VALUE is omitted, the CLI will attempt to use the value of the environment
variable of same name in the CLI process' environment. In this case, a warning
@ -67,9 +67,13 @@ export default class EnvAddCmd extends Command {
public static examples = [
'$ balena env add TERM --application MyApp',
'$ balena env add EDITOR vim --application MyApp',
'$ balena env add EDITOR vim --application MyApp,MyApp2',
'$ balena env add EDITOR vim --application MyApp --service MyService',
'$ balena env add EDITOR vim --application MyApp,MyApp2 --service MyService,MyService2',
'$ balena env add EDITOR vim --device 7cf02a6',
'$ balena env add EDITOR vim --device 7cf02a6,d6f1433',
'$ balena env add EDITOR vim --device 7cf02a6 --service MyService',
'$ balena env add EDITOR vim --device 7cf02a6,d6f1433 --service MyService,MyService2',
];
public static args = [
@ -86,9 +90,7 @@ export default class EnvAddCmd extends Command {
},
];
// hardcoded 'env add' to avoid oclif's 'env:add' topic syntax
public static usage =
'env add ' + new CommandHelp({ args: EnvAddCmd.args }).defaultUsage();
public static usage = 'env add <name> [value]';
public static flags: flags.Input<FlagsDef> = {
application: { exclusive: ['device'], ...cf.application },
@ -147,17 +149,31 @@ export default class EnvAddCmd extends Command {
const varType = isConfigVar ? 'configVar' : 'envVar';
if (options.application) {
await balena.models.application[varType].set(
options.application,
params.name,
params.value,
);
for (const app of options.application.split(',')) {
try {
await balena.models.application[varType].set(
app,
params.name,
params.value,
);
} catch (err) {
console.error(`${err.message}, app: ${app}`);
process.exitCode = 1;
}
}
} else if (options.device) {
await balena.models.device[varType].set(
options.device,
params.name,
params.value,
);
for (const device of options.device.split(',')) {
try {
await balena.models.device[varType].set(
device,
params.name,
params.value,
);
} catch (err) {
console.error(`${err.message}, device: ${device}`);
process.exitCode = 1;
}
}
}
}
}
@ -171,31 +187,57 @@ async function setServiceVars(
options: FlagsDef,
) {
if (options.application) {
const serviceId = await getServiceIdForApp(
sdk,
options.application,
options.service!,
);
await sdk.models.service.var.set(serviceId, params.name, params.value!);
} else {
for (const app of options.application.split(',')) {
for (const service of options.service!.split(',')) {
try {
const serviceId = await getServiceIdForApp(sdk, app, service);
await sdk.models.service.var.set(
serviceId,
params.name,
params.value!,
);
} catch (err) {
console.error(`${err.message}, application: ${app}`);
process.exitCode = 1;
}
}
}
} else if (options.device) {
const { getDeviceAndAppFromUUID } = await import('../../utils/cloud');
const [device, app] = await getDeviceAndAppFromUUID(
sdk,
options.device!,
['id'],
['app_name'],
);
const serviceId = await getServiceIdForApp(
sdk,
app.app_name,
options.service!,
);
await sdk.models.device.serviceVar.set(
device.id,
serviceId,
params.name,
params.value!,
);
for (const uuid of options.device.split(',')) {
let device;
let app;
try {
[device, app] = await getDeviceAndAppFromUUID(
sdk,
uuid,
['id'],
['app_name'],
);
} catch (err) {
console.error(`${err.message}, device: ${uuid}`);
process.exitCode = 1;
continue;
}
for (const service of options.service!.split(',')) {
try {
const serviceId = await getServiceIdForApp(
sdk,
app.app_name,
service,
);
await sdk.models.device.serviceVar.set(
device.id,
serviceId,
params.name,
params.value!,
);
} catch (err) {
console.error(`${err.message}, service: ${service}`);
process.exitCode = 1;
}
}
}
}
}

View File

@ -20,7 +20,6 @@ import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
@ -70,9 +69,7 @@ export default class EnvRenameCmd extends Command {
},
];
// hardcoded 'env rename' to avoid oclif's 'env:rename' topic syntax
public static usage =
'env rename ' + new CommandHelp({ args: EnvRenameCmd.args }).defaultUsage();
public static usage = 'env rename <id> <value>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig,

View File

@ -20,7 +20,6 @@ import Command from '../../command';
import * as ec from '../../utils/env-common';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
import { parseAsInteger } from '../../utils/validation';
type IArg<T> = import('@oclif/parser').args.IArg<T>;
@ -67,9 +66,7 @@ export default class EnvRmCmd extends Command {
},
];
// hardcoded 'env rm' to avoid oclif's 'env:rm' topic syntax
public static usage =
'env rm ' + new CommandHelp({ args: EnvRmCmd.args }).defaultUsage();
public static usage = 'env rm <id>';
public static flags: flags.Input<FlagsDef> = {
config: ec.booleanConfig,

View File

@ -317,7 +317,7 @@ async function getAppVars(
appVars.push(...vars);
}
if (!options.config && (options.service || options.all)) {
const pineOpts: SDK.PineOptionsFor<SDK.ServiceEnvironmentVariable> = {
const pineOpts: SDK.PineOptions<SDK.ServiceEnvironmentVariable> = {
$expand: {
service: {},
},
@ -359,7 +359,7 @@ async function getDeviceVars(
deviceVars.push(...deviceConfigVars);
} else {
if (options.service || options.all) {
const pineOpts: SDK.PineOptionsFor<SDK.DeviceServiceEnvironmentVariable> = {
const pineOpts: SDK.PineOptions<SDK.DeviceServiceEnvironmentVariable> = {
$expand: {
service_install: {
$expand: 'installs__service',

View File

@ -40,7 +40,16 @@ export default class ScandevicesCmd extends Command {
public async run() {
const { forms } = await import('balena-sync');
const hostnameOrIp = await forms.selectLocalBalenaOsDevice();
return console.error(`==> Selected device: ${hostnameOrIp}`);
try {
const hostnameOrIp = await forms.selectLocalBalenaOsDevice();
return console.error(`==> Selected device: ${hostnameOrIp}`);
} catch (e) {
if (e.message.toLowerCase().includes('could not find any')) {
const { ExpectedError } = await import('../../errors');
throw new ExpectedError(e);
} else {
throw e;
}
}
}
}

View File

@ -19,9 +19,11 @@ import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { parseAsLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
application?: string;
pollInterval?: number;
help?: void;
}
@ -61,6 +63,7 @@ export default class JoinCmd extends Command {
{
name: 'deviceIpOrHostname',
description: 'the IP or hostname of device',
parse: parseAsLocalHostnameOrIp,
},
];
@ -72,6 +75,10 @@ export default class JoinCmd extends Command {
description: 'the name of the application the device should join',
...cf.application,
},
pollInterval: flags.integer({
description: 'the interval in minutes to check for updates',
char: 'i',
}),
help: cf.help,
};
@ -83,15 +90,15 @@ export default class JoinCmd extends Command {
JoinCmd,
);
const Logger = await import('../utils/logger');
const promote = await import('../utils/promote');
const sdk = getBalenaSdk();
const logger = Logger.getLogger();
const logger = await Command.getLogger();
return promote.join(
logger,
sdk,
params.deviceIpOrHostname,
options.application,
options.pollInterval,
);
}
}

View File

@ -34,15 +34,30 @@ export default class KeyAddCmd extends Command {
public static description = stripIndent`
Add an SSH key to balenaCloud.
Register an SSH in balenaCloud for the logged in user.
Add an SSH key to the balenaCloud account of the logged in user.
If \`path\` is omitted, the command will attempt
to read the SSH key from stdin.
If \`path\` is omitted, the command will attempt to read the SSH key from stdin.
About SSH keys
An "SSH key" actually consists of a public/private key pair. A typical name
for the private key file is "id_rsa", and a typical name for the public key
file is "id_rsa.pub". Both key files are saved to your computer (with the
private key optionally protected by a password), but only the public key is
saved to your balena account. This means that if you change computers or
otherwise lose the private key, you cannot recover the private key through
your balena account. You can however add new keys, and delete the old ones.
To generate a new SSH key pair, a nice guide can be found in GitHub's docs:
https://help.github.com/en/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent
Skip the step about adding the key to a GitHub account, and instead add it to
your balena account.
`;
public static examples = [
'$ balena key add Main ~/.ssh/id_rsa.pub',
'$ cat ~/.ssh/id_rsa.pub | balena key add Main',
'# Windows 10 (cmd.exe prompt) example',
'$ balena key add Main %userprofile%.sshid_rsa.pub',
];
public static args = [

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 { parseAsLocalHostnameOrIp } from '../utils/validation';
interface FlagsDef {
help?: void;
@ -54,10 +55,10 @@ export default class LeaveCmd extends Command {
{
name: 'deviceIpOrHostname',
description: 'the device IP or hostname',
parse: parseAsLocalHostnameOrIp,
},
];
// Hardcoded to preserve camelcase
public static usage = 'leave [deviceIpOrHostname]';
public static flags: flags.Input<FlagsDef> = {
@ -70,10 +71,9 @@ export default class LeaveCmd extends Command {
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(LeaveCmd);
const Logger = await import('../utils/logger');
const promote = await import('../utils/promote');
const sdk = getBalenaSdk();
const logger = Logger.getLogger();
const logger = await Command.getLogger();
return promote.leave(logger, sdk, params.deviceIpOrHostname);
}
}

View File

@ -59,13 +59,13 @@ export default class LocalConfigureCmd extends Command {
public async run() {
const { args: params } = this.parse<FlagsDef, ArgsDef>(LocalConfigureCmd);
const Bluebird = await import('bluebird');
const { promisify } = await import('util');
const path = await import('path');
const umount = await import('umount');
const umountAsync = Bluebird.promisify(umount.umount);
const isMountedAsync = Bluebird.promisify(umount.isMounted);
const umountAsync = promisify(umount.umount);
const isMountedAsync = promisify(umount.isMounted);
const reconfix = await import('reconfix');
const denymount = Bluebird.promisify(await import('denymount'));
const denymount = promisify(await import('denymount'));
const Logger = await import('../../utils/logger');
const logger = Logger.getLogger();
@ -89,20 +89,17 @@ export default class LocalConfigureCmd extends Command {
const dmHandler = (cb: () => void) =>
reconfix
.readConfiguration(configurationSchema, params.target)
.tap((config: any) => {
.then(async (config: any) => {
logger.logDebug('Current config:');
logger.logDebug(JSON.stringify(config));
})
.then((config: any) => this.getConfiguration(config))
.tap((config: any) => {
const answers = await this.getConfiguration(config);
logger.logDebug('New config:');
logger.logDebug(JSON.stringify(config));
})
.then(async (answers: any) => {
logger.logDebug(JSON.stringify(answers));
if (!answers.hostname) {
await this.removeHostname(configurationSchema);
}
return reconfix.writeConfiguration(
return await reconfix.writeConfiguration(
configurationSchema,
answers,
params.target,
@ -119,9 +116,7 @@ export default class LocalConfigureCmd extends Command {
readonly CONNECTIONS_FOLDER = '/system-connections';
getConfigurationSchema(connectionFileName?: string) {
if (connectionFileName == null) {
connectionFileName = 'resin-wifi';
}
connectionFileName ??= 'resin-wifi';
return {
mapper: [
{
@ -222,9 +217,8 @@ export default class LocalConfigureCmd extends Command {
persistentLogging: data.persistentLogging || false,
});
return inquirer
.prompt(this.inquirerOptions(data))
.then((answers: any) => _.merge(data, answers));
const answers = await inquirer.prompt(this.inquirerOptions(data));
return _.merge(data, answers);
};
// Taken from https://goo.gl/kr1kCt
@ -261,62 +255,50 @@ export default class LocalConfigureCmd extends Command {
const _ = await import('lodash');
const imagefs = await import('resin-image-fs');
return imagefs
.listDirectory({
image: target,
partition: this.BOOT_PARTITION,
path: this.CONNECTIONS_FOLDER,
})
.then((files: string[]) => {
// The required file already exists
if (_.includes(files, 'resin-wifi')) {
return null;
}
const files = await imagefs.listDirectory({
image: target,
partition: this.BOOT_PARTITION,
path: this.CONNECTIONS_FOLDER,
});
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
if (_.includes(files, 'resin-sample.ignore')) {
return imagefs
.copy(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
},
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
)
.thenReturn(null);
}
// Legacy mode, to be removed later
// We return the file name override from this branch
// When it is removed the following cleanup should be done:
// * delete all the null returns from this method
// * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
// * drop the final `then` from this method
// * adapt the code in the main listener to not receive the config from this method, and use that constant instead
if (_.includes(files, 'resin-sample')) {
return 'resin-sample';
}
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
return imagefs
.writeFile(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
this.CONNECTION_FILE,
)
.thenReturn(null);
})
.then((connectionFileName) =>
this.getConfigurationSchema(connectionFileName || undefined),
let connectionFileName;
if (_.includes(files, 'resin-wifi')) {
// The required file already exists, nothing to do
} else if (_.includes(files, 'resin-sample.ignore')) {
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
await imagefs.copy(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
},
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
);
} else if (_.includes(files, 'resin-sample')) {
// Legacy mode, to be removed later
// We return the file name override from this branch
// When it is removed the following cleanup should be done:
// * delete all the null returns from this method
// * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
// * drop the final `then` from this method
// * adapt the code in the main listener to not receive the config from this method, and use that constant instead
connectionFileName = 'resin-sample';
} else {
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
await imagefs.writeFile(
{
image: target,
partition: this.BOOT_PARTITION,
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
},
this.CONNECTION_FILE,
);
}
return await this.getConfigurationSchema(connectionFileName);
}
async removeHostname(schema: any) {

View File

@ -28,6 +28,7 @@ interface FlagsDef {
email?: string;
user?: string;
password?: string;
port?: number;
help: void;
}
@ -73,14 +74,17 @@ export default class LoginCmd extends Command {
web: flags.boolean({
char: 'w',
description: 'web-based login',
exclusive: ['token', 'credentials'],
}),
token: flags.boolean({
char: 't',
description: 'session token or API key',
exclusive: ['web', 'credentials'],
}),
credentials: flags.boolean({
char: 'c',
description: 'credential-based login',
exclusive: ['web', 'token'],
}),
email: flags.string({
char: 'e',
@ -101,6 +105,12 @@ export default class LoginCmd extends Command {
description: 'password',
dependsOn: ['credentials'],
}),
port: flags.integer({
char: 'P',
description:
'TCP port number of local HTTP login server (--web auth only)',
dependsOn: ['web'],
}),
help: cf.help,
};
@ -122,7 +132,7 @@ export default class LoginCmd extends Command {
console.log(messages.balenaAsciiArt);
console.log(`\nLogging in to ${balenaUrl}`);
await this.doLogin(options, params.token);
await this.doLogin(options, balenaUrl, params.token);
const username = await balena.auth.whoami();
@ -134,14 +144,13 @@ Find out about the available commands by running:
$ balena help
${messages.reachingOut}`);
if (options.web) {
const { shutdownServer } = await import('../auth');
shutdownServer();
}
}
async doLogin(loginOptions: FlagsDef, token?: string): Promise<void> {
async doLogin(
loginOptions: FlagsDef,
balenaUrl: string = 'balena-cloud.com',
token?: string,
): Promise<void> {
// Token
if (loginOptions.token) {
if (!token) {
@ -166,15 +175,15 @@ ${messages.reachingOut}`);
// Web
else if (loginOptions.web) {
const auth = await import('../auth');
await auth.login();
await auth.login({ port: loginOptions.port });
return;
} else {
const patterns = await import('../utils/patterns');
// User had not selected login preference, prompt interactively
const loginType = await patterns.askLoginType();
if (loginType === 'register') {
const signupUrl = 'https://dashboard.balena-cloud.com/signup';
const open = await import('open');
const signupUrl = `https://dashboard.${balenaUrl}/signup`;
open(signupUrl, { wait: false });
throw new ExpectedError(`Please sign up at ${signupUrl}`);
}

View File

@ -115,10 +115,8 @@ export default class LogsCmd extends Command {
const displayCloudLog = async (line: LogMessage) => {
if (!line.isSystem) {
let serviceName = await serviceIdToName(balena, line.serviceId);
if (serviceName == null) {
serviceName = 'Unknown service';
}
const serviceName =
(await serviceIdToName(balena, line.serviceId)) ?? 'Unknown service';
displayLogObject(
{ serviceName, ...line },
logger,

View File

@ -39,7 +39,8 @@ export default class NoteCmd extends Command {
it will be read from stdin.
To view device notes, use the \`balena device <uuid>\` command.
`;
`;
public static examples = [
'$ balena note "My useful note" --device 7cf02a6',
'$ cat note.txt | balena note --device 7cf02a6',

View File

@ -0,0 +1,128 @@
/**
* @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 { getCliForm, stripIndent } from '../../utils/lazy';
import * as _ from 'lodash';
import type { DeviceTypeJson } from 'balena-sdk';
interface FlagsDef {
advanced: boolean;
output: string;
help: void;
}
interface ArgsDef {
image: string;
'device-type': string;
}
export default class OsBuildConfigCmd extends Command {
public static description = stripIndent`
Build an OS config and save it to a JSON file.
Interactively generate an OS config once, so that the generated config
file can be used in \`balena os configure\`, skipping the interactive part.
`;
public static examples = [
'$ balena os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json',
'$ balena os configure ../path/rpi3.img --device 7cf02a6 --config rpi3-config.json',
];
public static args = [
{
name: 'image',
description: 'os image',
required: true,
},
{
name: 'device-type',
description: 'device type',
required: true,
},
];
public static usage = 'os build-config <image> <device-type>';
public static flags: flags.Input<FlagsDef> = {
advanced: flags.boolean({
description: 'show advanced configuration options',
char: 'v',
}),
output: flags.string({
description: 'path to output JSON file',
char: 'o',
required: true,
}),
help: cf.help,
};
public static authenticated = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
OsBuildConfigCmd,
);
const { writeFile } = (await import('fs')).promises;
const config = await this.buildConfig(
params.image,
params['device-type'],
options.advanced,
);
await writeFile(options.output, JSON.stringify(config, null, 4));
console.info(`Config file "${options.output}" created successfully.`);
}
async buildConfig(image: string, deviceTypeSlug: string, advanced: boolean) {
advanced = advanced || false;
const { getManifest } = await import('../../utils/helpers');
const deviceTypeManifest = await getManifest(image, deviceTypeSlug);
return this.buildConfigForDeviceType(deviceTypeManifest, advanced);
}
async buildConfigForDeviceType(
deviceTypeManifest: DeviceTypeJson.DeviceType,
advanced: boolean,
) {
advanced ??= false;
let override;
const questions = deviceTypeManifest.options;
if (!advanced) {
const advancedGroup = _.find(questions, {
name: 'advanced',
isGroup: true,
});
if (advancedGroup != null) {
const { getGroupDefaults } = await import('../../utils/helpers');
override = getGroupDefaults(advancedGroup);
}
}
return getCliForm().run(questions, { override });
}
}

View File

@ -24,7 +24,6 @@ import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent, getCliForm } from '../../utils/lazy';
import { CommandHelp } from '../../utils/oclif-utils';
const BOOT_PARTITION = 1;
const CONNECTIONS_FOLDER = '/system-connections';
@ -51,10 +50,6 @@ interface ArgsDef {
image: string;
}
interface DeferredDevice extends BalenaSdk.Device {
belongs_to__application: BalenaSdk.PineDeferred;
}
interface Answers {
appUpdatePollInterval: number; // in minutes
deviceType: string; // e.g. "raspberrypi3"
@ -115,10 +110,7 @@ export default class OsConfigureCmd extends Command {
},
];
// hardcoded 'os configure' to avoid oclif's 'os:configure' topic syntax
public static usage =
'os configure ' +
new CommandHelp({ args: OsConfigureCmd.args }).defaultUsage();
public static usage = 'os configure <image>';
public static flags: flags.Input<FlagsDef> = {
advanced: flags.boolean({
@ -192,18 +184,31 @@ export default class OsConfigureCmd extends Command {
'../../utils/config'
);
const helpers = await import('../../utils/helpers');
let app: BalenaSdk.Application | undefined;
let device: BalenaSdk.Device | undefined;
const { getApplication } = await import('../../utils/sdk');
let app: ApplicationWithDeviceType | undefined;
let device;
let deviceTypeSlug: string;
const balena = getBalenaSdk();
if (options.device) {
device = await balena.models['device'].get(options.device);
deviceTypeSlug = device.device_type;
device = (await balena.models.device.get(options.device, {
$expand: {
is_of__device_type: { $select: 'slug' },
},
})) as DeviceWithDeviceType & {
belongs_to__application: BalenaSdk.PineDeferred;
};
deviceTypeSlug = device.is_of__device_type[0].slug;
} else {
app = await balena.models['application'].get(options.application!);
app = (await getApplication(balena, options.application!, {
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceType;
await checkDeviceTypeCompatibility(balena, options, app);
deviceTypeSlug = options['device-type'] || app.device_type;
deviceTypeSlug =
options['device-type'] || app.is_for__device_type[0].slug;
}
const deviceTypeManifest = await helpers.getManifest(
@ -232,7 +237,7 @@ export default class OsConfigureCmd extends Command {
if (_.isEmpty(configJson)) {
if (device) {
configJson = await generateDeviceConfig(
device as DeferredDevice,
device,
options['device-api-key'],
answers,
);
@ -335,7 +340,7 @@ async function validateOptions(options: FlagsDef) {
*/
async function getOsVersionFromImage(
imagePath: string,
deviceTypeManifest: BalenaSdk.DeviceType,
deviceTypeManifest: BalenaSdk.DeviceTypeJson.DeviceType,
devInit: typeof import('balena-device-init'),
): Promise<string> {
const osVersion = await devInit.getImageOsVersion(
@ -361,11 +366,11 @@ async function getOsVersionFromImage(
async function checkDeviceTypeCompatibility(
sdk: BalenaSdk.BalenaSDK,
options: FlagsDef,
app: BalenaSdk.Application,
app: ApplicationWithDeviceType,
) {
if (options['device-type']) {
const [appDeviceType, optionDeviceType] = await Promise.all([
sdk.models.device.getManifestBySlug(app.device_type),
sdk.models.device.getManifestBySlug(app.is_for__device_type[0].slug),
sdk.models.device.getManifestBySlug(options['device-type']),
]);
const helpers = await import('../../utils/helpers');
@ -392,7 +397,7 @@ async function checkDeviceTypeCompatibility(
* The questions are extracted from the given deviceType "manifest".
*/
async function askQuestionsForDeviceType(
deviceType: BalenaSdk.DeviceType,
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
options: FlagsDef,
configJson?: import('../../utils/config').ImgConfig,
): Promise<Answers> {
@ -460,14 +465,17 @@ async function askQuestionsForDeviceType(
* [ 'network', 'wifiSsid', 'wifiKey', 'appUpdatePollInterval' ]
*/
function getQuestionNames(
deviceType: BalenaSdk.DeviceType,
deviceType: BalenaSdk.DeviceTypeJson.DeviceType,
): Array<keyof Answers> {
const questionNames: string[] = _.chain(deviceType.options)
.flatMap(
(group: BalenaSdk.DeviceTypeOptions) =>
(group: BalenaSdk.DeviceTypeJson.DeviceTypeOptions) =>
(group.isGroup && group.options) || [],
)
.map((groupOption: BalenaSdk.DeviceTypeOptionsGroup) => groupOption.name)
.map(
(groupOption: BalenaSdk.DeviceTypeJson.DeviceTypeOptionsGroup) =>
groupOption.name,
)
.filter()
.value();
return questionNames as Array<keyof Answers>;

View File

@ -46,11 +46,15 @@ export default class OsDownloadCmd extends Command {
You can pass \`--version menu\` to pick the OS version from the interactive menu
of all available versions.
To download a development image append \`.dev\` to the version or select from
the interactive menu.
`;
public static examples = [
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 2.60.1+rev1',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 2.60.1+rev1.dev',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^2.60.0',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default',
'$ balena os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu',

View File

@ -42,6 +42,7 @@ export default class OsVersionsCmd extends Command {
{
name: 'type',
description: 'device type',
required: true,
},
];

530
lib/commands/preload.ts Normal file
View File

@ -0,0 +1,530 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import {
getBalenaSdk,
getCliForm,
getVisuals,
stripIndent,
} from '../utils/lazy';
import type { DockerConnectionCliFlags } from '../utils/docker';
import { dockerConnectionCliFlags } from '../utils/docker';
import * as _ from 'lodash';
import type {
Application,
BalenaSDK,
DeviceTypeJson,
PineExpand,
Release,
} from 'balena-sdk';
import type { Preloader } from 'balena-preload';
import { parseAsInteger } from '../utils/validation';
import { ExpectedError } from '../errors';
interface FlagsDef extends DockerConnectionCliFlags {
app?: string;
commit?: string;
'splash-image'?: string;
'dont-check-arch': boolean;
'pin-device-to-release': boolean;
'add-certificate'?: string[];
help: void;
}
interface ArgsDef {
image: string;
}
export default class PreloadCmd extends Command {
public static description = stripIndent`
Preload an app on a disk image (or Edison zip archive).
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file
in the local disk (a zip file is only accepted for the Intel Edison device type).
After preloading, the balenaOS image file can be flashed to a device's SD card.
When the device boots, it will not need to download the application, as it was
preloaded.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)
check: https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
`;
public static examples = [
'$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png',
'$ balena preload balena.img',
];
public static args = [
{
name: 'image',
description: 'the image file path',
required: true,
},
];
public static usage = 'preload <image>';
public static flags: flags.Input<FlagsDef> = {
app: flags.string({
description: 'name of the application to preload',
char: 'a',
}),
commit: flags.string({
description: `\
The commit hash for a specific application release to preload, use "current" to specify the current
release (ignored if no appId is given). The current release is usually also the latest, but can be
manually pinned using https://github.com/balena-io-projects/staged-releases .\
`,
char: 'c',
}),
'splash-image': flags.string({
description: 'path to a png image to replace the splash screen',
char: 's',
}),
'dont-check-arch': flags.boolean({
description:
'disables check for matching architecture in image and application',
}),
'pin-device-to-release': flags.boolean({
description:
'pin the preloaded device to the preloaded release on provision',
char: 'p',
}),
'add-certificate': flags.string({
description: `\
Add the given certificate (in PEM format) to /etc/ssl/certs in the preloading container.
The file name must end with '.crt' and must not be already contained in the preloader's
/etc/ssl/certs folder.
Can be repeated to add multiple certificates.\
`,
multiple: true,
}),
...dockerConnectionCliFlags,
// Redefining --dockerPort here (defined already in dockerConnectionCliFlags)
// without -p alias, to avoid clash with -p alias of pin-device-to-release
dockerPort: flags.integer({
description:
'Docker daemon TCP port number (hint: 2375 for balena devices)',
parse: (p) => parseAsInteger(p, 'dockerPort'),
}),
// Not supporting -h for help, because of clash with -h in DockerCliFlags
// Revisit this in future release.
help: flags.help({}),
};
public static authenticated = true;
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
PreloadCmd,
);
const balena = getBalenaSdk();
const balenaPreload = await import('balena-preload');
const visuals = getVisuals();
const nodeCleanup = await import('node-cleanup');
const { instanceOf } = await import('../errors');
// Check image file exists
try {
const fs = await import('fs');
await fs.promises.access(params.image);
} catch (error) {
throw new ExpectedError(
`The provided image path does not exist: ${params.image}`,
);
}
const progressBars: {
[key: string]: ReturnType<typeof getVisuals>['Progress'];
} = {};
const progressHandler = function (event: {
name: string;
percentage: number;
}) {
const progressBar = (progressBars[event.name] ??= new visuals.Progress(
event.name,
));
return progressBar.update({ percentage: event.percentage });
};
const spinners: {
[key: string]: ReturnType<typeof getVisuals>['Spinner'];
} = {};
const spinnerHandler = function (event: { name: string; action: string }) {
const spinner = (spinners[event.name] ??= new visuals.Spinner(
event.name,
));
if (event.action === 'start') {
return spinner.start();
} else {
console.log();
return spinner.stop();
}
};
const commit = this.isCurrentCommit(options.commit || '')
? 'latest'
: options.commit;
const image = params.image;
const appId = options.app;
const splashImage = options['splash-image'];
const dontCheckArch = options['dont-check-arch'] || false;
const pinDevice = options['pin-device-to-release'] || false;
if (dontCheckArch && !appId) {
throw new ExpectedError(
'You need to specify an app id if you disable the architecture check.',
);
}
const certificates: string[] = options['add-certificate'] || [];
for (const certificate of certificates) {
if (!certificate.endsWith('.crt')) {
throw new ExpectedError('Certificate file name must end with ".crt"');
}
}
// Get a configured dockerode instance
const dockerUtils = await import('../utils/docker');
const docker = await dockerUtils.getDocker(options);
const preloader = new balenaPreload.Preloader(
null,
docker,
appId,
commit,
image,
splashImage,
undefined, // TODO: Currently always undefined, investigate approach in ssh command.
dontCheckArch,
pinDevice,
certificates,
);
let gotSignal = false;
nodeCleanup(function (_exitCode, signal) {
if (signal) {
gotSignal = true;
nodeCleanup.uninstall(); // don't call cleanup handler again
preloader.cleanup().then(() => {
// calling process.exit() won't inform parent process of signal
process.kill(process.pid, signal);
});
return false;
}
});
if (process.env.DEBUG) {
preloader.stderr.pipe(process.stderr);
}
preloader.on('progress', progressHandler);
preloader.on('spinner', spinnerHandler);
try {
await new Promise((resolve, reject) => {
preloader.on('error', reject);
resolve(
this.prepareAndPreload(preloader, balena, {
appId,
commit,
pinDevice,
}),
);
});
} catch (err) {
if (instanceOf(err, balena.errors.BalenaError)) {
const code = err.code ? `(${err.code})` : '';
throw new ExpectedError(`${err.message} ${code}`);
} else {
throw err;
}
} finally {
if (!gotSignal) {
await preloader.cleanup();
}
}
}
readonly applicationExpandOptions: PineExpand<Application> = {
owns__release: {
$select: ['id', 'commit', 'end_timestamp', 'composition'],
$orderby: [{ end_timestamp: 'desc' }, { id: 'desc' }],
$expand: {
contains__image: {
$select: ['image'],
$expand: {
image: {
$select: ['image_size', 'is_stored_at__image_location'],
},
},
},
},
$filter: {
status: 'success',
},
},
should_be_running__release: {
$select: 'commit',
},
};
allDeviceTypes: DeviceTypeJson.DeviceType[];
async getDeviceTypes() {
if (this.allDeviceTypes === undefined) {
const balena = getBalenaSdk();
const deviceTypes = await balena.models.config.getDeviceTypes();
this.allDeviceTypes = _.sortBy(deviceTypes, 'name');
}
return this.allDeviceTypes;
}
isCurrentCommit(commit: string) {
return commit === 'latest' || commit === 'current';
}
async getDeviceTypesWithSameArch(deviceTypeSlug: string) {
const deviceTypes = await this.getDeviceTypes();
const deviceType = _.find(deviceTypes, { slug: deviceTypeSlug });
if (!deviceType) {
throw new Error(`Device type "${deviceTypeSlug}" not found in API query`);
}
return _(deviceTypes).filter({ arch: deviceType.arch }).map('slug').value();
}
async getApplicationsWithSuccessfulBuilds(deviceTypeSlug: string) {
const balena = getBalenaSdk();
const deviceTypes = await this.getDeviceTypesWithSameArch(deviceTypeSlug);
// TODO: remove the explicit types once https://github.com/balena-io/balena-sdk/pull/889 gets merged
return balena.pine.get<
Application,
Array<
ApplicationWithDeviceType & {
should_be_running__release: [Release?];
}
>
>({
resource: 'my_application',
options: {
$filter: {
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: {
dt: {
slug: { $in: deviceTypes },
},
},
},
},
owns__release: {
$any: {
$alias: 'r',
$expr: {
r: {
status: 'success',
},
},
},
},
},
$expand: this.applicationExpandOptions,
$select: ['id', 'app_name', 'should_track_latest_release'],
$orderby: 'app_name asc',
},
});
}
async selectApplication(deviceTypeSlug: string) {
const visuals = getVisuals();
const applicationInfoSpinner = new visuals.Spinner(
'Downloading list of applications and releases.',
);
applicationInfoSpinner.start();
const applications = await this.getApplicationsWithSuccessfulBuilds(
deviceTypeSlug,
);
applicationInfoSpinner.stop();
if (applications.length === 0) {
throw new ExpectedError(
`You have no apps with successful releases for a '${deviceTypeSlug}' device type.`,
);
}
return getCliForm().ask({
message: 'Select an application',
type: 'list',
choices: applications.map((app) => ({
name: app.app_name,
value: app,
})),
});
}
selectApplicationCommit(releases: Release[]) {
if (releases.length === 0) {
throw new ExpectedError('This application has no successful releases.');
}
const DEFAULT_CHOICE = { name: 'current', value: 'current' };
const choices = [DEFAULT_CHOICE].concat(
releases.map((release) => ({
name: `${release.end_timestamp} - ${release.commit}`,
value: release.commit,
})),
);
return getCliForm().ask({
message: 'Select a release',
type: 'list',
default: 'current',
choices,
});
}
async offerToDisableAutomaticUpdates(
application: Application,
commit: string,
pinDevice: boolean,
) {
const balena = getBalenaSdk();
if (
this.isCurrentCommit(commit) ||
!application.should_track_latest_release ||
pinDevice
) {
return;
}
const message = `\
This application is set to track the latest release, and non-pinned devices
are automatically updated when a new release is available. This may lead to
unexpected behavior: The preloaded device will download and install the latest
release once it is online.
This prompt gives you the opportunity to disable automatic updates for this
application now. Note that this would result in the application being pinned
to the current latest release, rather than some other release that may have
been selected for preloading. The pinned released may be further managed
through the web dashboard or programatically through the balena API / SDK.
Documentation about release policies and app/device pinning can be found at:
https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/
Alternatively, the --pin-device-to-release flag may be used to pin only the
preloaded device to the selected release.
Would you like to disable automatic updates for this application now?\
`;
const update = await getCliForm().ask({
message,
type: 'confirm',
});
if (!update) {
return;
}
return await balena.pine.patch({
resource: 'application',
id: application.id,
body: {
should_track_latest_release: false,
},
});
}
async getAppWithReleases(balenaSdk: BalenaSDK, appId: string | number) {
const { getApplication } = await import('../utils/sdk');
return (await getApplication(balenaSdk, appId, {
$expand: this.applicationExpandOptions,
})) as Application & { should_be_running__release: [Release?] };
}
async prepareAndPreload(
preloader: Preloader,
balenaSdk: BalenaSDK,
options: {
appId?: string;
commit?: string;
pinDevice: boolean;
},
) {
await preloader.prepare();
const application = options.appId
? await this.getAppWithReleases(balenaSdk, options.appId)
: await this.selectApplication(preloader.config.deviceType);
let commit: string; // commit hash or the strings 'latest' or 'current'
const appCommit = application.should_be_running__release[0]?.commit;
// Use the commit given as --commit or show an interactive commit selection menu
if (options.commit) {
if (this.isCurrentCommit(options.commit)) {
if (!appCommit) {
throw new Error(
`Unexpected empty commit hash for app ID "${application.id}"`,
);
}
// handle `--commit current` (and its `--commit latest` synonym)
commit = 'latest';
} else {
const release = _.find(application.owns__release, (r) =>
r.commit.startsWith(options.commit!),
);
if (!release) {
throw new ExpectedError(
`There is no release matching commit "${options.commit}"`,
);
}
commit = release.commit;
}
} else {
// this could have the value 'current'
commit = await this.selectApplicationCommit(
application.owns__release as Release[],
);
}
await preloader.setAppIdAndCommit(
application.id,
this.isCurrentCommit(commit) ? appCommit! : commit,
);
// Propose to disable automatic app updates if the commit is not the current release
await this.offerToDisableAutomaticUpdates(
application,
commit,
options.pinDevice,
);
// All options are ready: preload the image.
await preloader.preload();
}
}

419
lib/commands/push.ts Normal file
View File

@ -0,0 +1,419 @@
/**
* @license
* Copyright 2016-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { flags } from '@oclif/command';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { BalenaSDK, Application, Organization } from 'balena-sdk';
import { ExpectedError, instanceOf } from '../errors';
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;
}
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 builders, or a local mode balena device.
When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the
"git push" deployment method.
When building on a local mode device, the given source directory will be
built on the device, and the resulting containers will be run on the device.
Logs will be streamed back from the device as part of the same invocation.
The web dashboard can be used to switch a device to local mode:
https://www.balena.io/docs/learn/develop/local-mode/
Note that local mode requires a supervisor version of at least v7.21.0.
The logs from only a single service can be shown with the --service flag, and
showing only the system logs can be achieved with --system. Note that these
flags can be used together.
When pushing to a local device a live session will be started.
The project source folder is watched for filesystem events, and changes
to files and folders are automatically synchronized to the running
containers. The synchronization is only in one direction, from this machine to
the device, and changes made on the device itself may be overwritten.
This feature requires a device running supervisor version v9.7.0 or greater.
${registrySecretsHelp.split('\n').join('\n\t\t')}
${dockerignoreHelp.split('\n').join('\n\t\t')}
Note: the --service and --env flags must come after the applicationOrDevice
parameter, as per examples.
`;
public static examples = [
'$ balena push myApp',
'$ balena push myApp --source <source directory>',
'$ balena push myApp -s <source directory>',
'',
'$ balena push 10.0.0.1',
'$ balena push 10.0.0.1 --source <source directory>',
'$ balena push 10.0.0.1 --service my-service',
'$ balena push 10.0.0.1 --env MY_ENV_VAR=value --env my-service:SERVICE_VAR=value',
'$ balena push 10.0.0.1 --nolive',
'',
'$ balena push 23c73a1.local --system',
'$ balena push 23c73a1.local --system --service my-service',
];
public static args = [
{
name: 'applicationOrDevice',
description: 'application name, or device address (for local pushes)',
required: true,
},
];
public static usage = 'push <applicationOrDevice>';
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description: stripIndent`
Source directory to be sent to balenaCloud or balenaOS device
(default: current working dir)`,
char: 's',
}),
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).`,
char: 'e',
}),
dockerfile: flags.string({
description:
'Alternative Dockerfile name/path, relative to the source folder',
}),
nocache: flags.boolean({
description: stripIndent`
Don't use cached layers of previously built images for this project. This
ensures that the latest base image and packages are pulled. Note that build
logs may still display the message _"Pulling previous images for caching
purposes" (as the cloud builder needs previous images to compute delta
updates), but the logs will not display the "Using cache" lines for each
build step of a Dockerfile.`,
char: 'c',
}),
pull: flags.boolean({
description: stripIndent`
When pushing to a local device, force the base images to be pulled again.
Currently this option is ignored when pushing to the balenaCloud builders.`,
}),
'noparent-check': flags.boolean({
description: stripIndent`
Disable project validation check of 'docker-compose.yml' file in parent folder`,
}),
'registry-secrets': flags.string({
description: stripIndent`
Path to a local YAML or JSON file containing Docker registry passwords used
to pull base images. Note that if registry-secrets are not provided on the
command line, a secrets configuration file from the balena directory will be
used (usually $HOME/.balena/secrets.yml|.json)`,
char: 'R',
}),
nolive: flags.boolean({
description: stripIndent`
Don't run a live session on this push. The filesystem will not be monitored,
and changes will not be synchronized to any running containers. Note that both
this flag and --detached and required to cause the process to end once the
initial build has completed.`,
}),
detached: flags.boolean({
description: stripIndent`
When pushing to the cloud, this option will cause the build to start, then
return execution back to the shell, with the status and release ID (if
applicable). When pushing to a local mode device, this option will cause
the command to not tail application logs when the build has completed.`,
char: 'd',
}),
service: flags.string({
description: stripIndent`
Reject logs not originating from this service.
This can be used in combination with --system and other --service flags.
Only valid when pushing to a local mode device.`,
multiple: true,
}),
system: flags.boolean({
description: stripIndent`
Only show system logs. This can be used in combination with --service.
Only valid when pushing to a local mode device.`,
}),
env: flags.string({
description: stripIndent`
When performing a push to device, run the built containers with environment
variables provided with this argument. Environment variables can be applied
to individual services by adding their service name before the argument,
separated by a colon, e.g:
--env main:MY_ENV=value
Note that if the service name cannot be found in the composition, the entire
left hand side of the = character will be treated as the variable name.
`,
multiple: true,
}),
'convert-eol': flags.boolean({
description: 'No-op and deprecated since balena CLI v12.0.0',
char: 'l',
hidden: true,
}),
'noconvert-eol': flags.boolean({
description: `Don't convert line endings from CRLF (Windows format) to LF (Unix format).`,
}),
'multi-dockerignore': flags.boolean({
description:
'Have each service use its own .dockerignore file. See "balena help push".',
char: 'm',
exclusive: ['gitignore'],
}),
nogitignore: flags.boolean({
description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
char: 'G',
hidden: true,
}),
gitignore: flags.boolean({
description: stripIndent`
Consider .gitignore files in addition to the .dockerignore file. This reverts
to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted.`,
char: 'g',
exclusive: ['multi-dockerignore'],
}),
help: cf.help,
};
public static primary = true;
public async run() {
const { args: params, flags: options } = this.parse<FlagsDef, ArgsDef>(
PushCmd,
);
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,
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) {
case BuildTarget.Cloud:
const remote = await import('../utils/remote-build');
// 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,
sdk,
opts,
};
await remote.startRemoteBuild(args);
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;
}
}
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');
// 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,
);
return selected.extra;
}
}

View File

@ -19,9 +19,10 @@ import { flags } from '@oclif/command';
import type { LocalBalenaOsDevice } from 'balena-sync';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getVisuals, stripIndent } from '../utils/lazy';
import { getCliUx, stripIndent } from '../utils/lazy';
interface FlagsDef {
json?: boolean;
verbose: boolean;
timeout?: number;
help: void;
@ -32,6 +33,11 @@ export default class ScanCmd extends Command {
Scan for balenaOS devices on your local network.
Scan for balenaOS devices on your local network.
The output includes device information collected through balenaEngine for
devices running a development image of balenaOS. Devices running a production
image do not expose balenaEngine (on TCP port 2375), which is why less
information is printed about them.
`;
public static examples = [
@ -53,18 +59,19 @@ export default class ScanCmd extends Command {
description: 'scan timeout in seconds',
}),
help: cf.help,
json: flags.boolean({
char: 'j',
description: 'produce JSON output instead of tabular output',
}),
};
public static primary = true;
public static root = true;
public async run() {
const Bluebird = await import('bluebird');
const _ = await import('lodash');
const { SpinnerPromise } = getVisuals();
const { discover } = await import('balena-sync');
const prettyjson = await import('prettyjson');
const { ExpectedError } = await import('../errors');
const dockerUtils = await import('../utils/docker');
const dockerPort = 2375;
@ -76,55 +83,75 @@ export default class ScanCmd extends Command {
options.timeout != null ? options.timeout * 1000 : undefined;
// Find active local devices
const activeLocalDevices: LocalBalenaOsDevice[] = await new SpinnerPromise({
promise: discover.discoverLocalBalenaOsDevices(discoverTimeout),
startMessage: 'Scanning for local balenaOS devices..',
stopMessage: 'Reporting scan results',
}).filter(async ({ address }: { address: string }) => {
const docker = dockerUtils.createClient({
host: address,
port: dockerPort,
timeout: dockerTimeout,
}) as any;
try {
await docker.pingAsync();
return true;
} catch (err) {
return false;
}
});
const ux = getCliUx();
ux.action.start('Scanning for local balenaOS devices');
// Exit with message if no devices found
if (_.isEmpty(activeLocalDevices)) {
// TODO: Consider whether this should really be an error
throw new ExpectedError(
process.platform === 'win32'
? ScanCmd.noDevicesFoundMessage + ScanCmd.windowsTipMessage
: ScanCmd.noDevicesFoundMessage,
);
}
// Query devices for info
const devicesInfo = await Promise.all(
activeLocalDevices.map(({ host, address }) => {
const localDevices: LocalBalenaOsDevice[] = await discover.discoverLocalBalenaOsDevices(
discoverTimeout,
);
const engineReachableDevices: boolean[] = await Promise.all(
localDevices.map(async ({ address }: { address: string }) => {
const docker = dockerUtils.createClient({
host: address,
port: dockerPort,
timeout: dockerTimeout,
}) as any;
return Bluebird.props({
host,
address,
dockerInfo: docker
.infoAsync()
.catchReturn('Could not get Docker info'),
dockerVersion: docker
.versionAsync()
.catchReturn('Could not get Docker version'),
});
try {
await docker.pingAsync();
return true;
} catch (err) {
return false;
}
}),
);
const developmentDevices: LocalBalenaOsDevice[] = localDevices.filter(
(_localDevice, index) => engineReachableDevices[index],
);
const productionDevices = _.differenceWith(
localDevices,
developmentDevices,
_.isEqual,
);
const productionDevicesInfo = _.map(
productionDevices,
(device: LocalBalenaOsDevice) => {
return {
host: device.host,
address: device.address,
osVariant: 'production',
dockerInfo: undefined,
dockerVersion: undefined,
};
},
);
// Query devices for info
const devicesInfo = await Promise.all(
developmentDevices.map(async ({ host, address }) => {
const docker = dockerUtils.createClient({
host: address,
port: dockerPort,
timeout: dockerTimeout,
}) as any;
const [dockerInfo, dockerVersion] = await Promise.all([
docker.infoAsync().catchReturn('Could not get Docker info'),
docker.versionAsync().catchReturn('Could not get Docker version'),
]);
return {
host,
address,
osVariant: 'development',
dockerInfo,
dockerVersion,
};
}),
);
ux.action.stop('Reporting scan results');
// Reduce properties if not --verbose
if (!options.verbose) {
devicesInfo.forEach((d: any) => {
@ -137,8 +164,22 @@ export default class ScanCmd extends Command {
});
}
const cmdOutput = productionDevicesInfo.concat(devicesInfo);
// Output results
console.log(prettyjson.render(devicesInfo, { noColor: true }));
if (!options.json && cmdOutput.length === 0) {
console.error(
process.platform === 'win32'
? ScanCmd.noDevicesFoundMessage + ScanCmd.windowsTipMessage
: ScanCmd.noDevicesFoundMessage,
);
return;
}
console.log(
options.json
? JSON.stringify(cmdOutput, null, 4)
: prettyjson.render(cmdOutput, { noColor: true }),
);
}
protected static dockerInfoProperties = [

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