mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-06-24 18:45:07 +00:00
Compare commits
693 Commits
Author | SHA1 | Date | |
---|---|---|---|
cccc8012c9 | |||
29bfcf7ac5 | |||
2091768c84 | |||
36ab6f5808 | |||
b45e80654c | |||
8c60c9e076 | |||
d47fe0609f | |||
3b5f3c6665 | |||
1bfba85d58 | |||
cb14928866 | |||
4088e4c66e | |||
01a1bcdc8a | |||
05c3d2a5db | |||
7da250914e | |||
eaad5377b4 | |||
9f15ee58df | |||
ee267cd114 | |||
b35a51ef3a | |||
7ce43f4018 | |||
3ba8be02e7 | |||
9a9d3f5c32 | |||
0adaeb5465 | |||
783cab2e50 | |||
a47d2d4454 | |||
1f728050c8 | |||
15ec99577a | |||
fb2e498aa9 | |||
7529a9a2a2 | |||
22b02c261f | |||
8f014710c0 | |||
308d1afb83 | |||
c15276d239 | |||
ecae517de0 | |||
69cc2a0946 | |||
7a8fc14686 | |||
eaa886c31c | |||
20ae2bc57a | |||
96c975d17e | |||
ff8d784582 | |||
53bee83047 | |||
6e343c36a8 | |||
e29c275b4c | |||
7faf363180 | |||
a503cb4757 | |||
b3470ac909 | |||
ba4c93ccf5 | |||
87401ad569 | |||
181afb34f8 | |||
bfdfa28922 | |||
21840d9245 | |||
d9c3332cb2 | |||
29d684f9c3 | |||
a832f47508 | |||
4557cf626f | |||
8c68aaad49 | |||
260c6fccd2 | |||
d40f2eb500 | |||
b6f3975bc1 | |||
f2bd3c0ffb | |||
3ae01fdaa0 | |||
2452b42f81 | |||
3303ac21c9 | |||
1b277bda87 | |||
5f67c243c0 | |||
9bbfb31bf7 | |||
5b805fe1da | |||
24ed25aa37 | |||
2ad0b60aeb | |||
37bd6be77b | |||
88ad591a83 | |||
30c36a26e2 | |||
6d6afc5140 | |||
fc79d89f10 | |||
57fba32fa2 | |||
b41f9b9261 | |||
a9aa7538f3 | |||
1b13d1b969 | |||
e6b09f1b94 | |||
8fa592dff0 | |||
a6d2950260 | |||
b22ddb50f1 | |||
0aa10ba2a1 | |||
56c74af1ff | |||
6460d850ca | |||
b05aa7b385 | |||
97c15208b5 | |||
375464eb1a | |||
811262ed8b | |||
f816cb4ce8 | |||
7b5272e926 | |||
d412d39164 | |||
d41fb72ded | |||
4676396b5f | |||
b97565d2e7 | |||
a697121b97 | |||
12615cd0dc | |||
cba73eec44 | |||
f5ed0648ba | |||
ac5ffeda09 | |||
db25a65753 | |||
296f1ae2de | |||
579cdaa2e2 | |||
69db3c0171 | |||
7c71098d86 | |||
490f833a33 | |||
a81c1971f1 | |||
76034696e9 | |||
454f82883e | |||
6a9a9e1fdb | |||
cf2ad66955 | |||
4cfaf6e666 | |||
bc563ea963 | |||
65ac35a93e | |||
1ee51ca9a7 | |||
e9e15dbbe3 | |||
a665a3d153 | |||
9da5f88ecf | |||
14e9b34636 | |||
e619caea42 | |||
a133fe8c6f | |||
29dd5e71a1 | |||
9b52dec725 | |||
6bc55ea7ab | |||
717affa591 | |||
6a9eeaaba2 | |||
98eaeddbfe | |||
30698c62e3 | |||
79e240f630 | |||
6825ffe416 | |||
b9bf00d329 | |||
5ae7457f45 | |||
d78dfcb1de | |||
95c4c59ca0 | |||
3a06c5df72 | |||
d30144a16a | |||
c0990fe6c4 | |||
af382bfee4 | |||
6705369ca6 | |||
fb05957198 | |||
6b21f5aa5a | |||
0fac8d8d3b | |||
f39193ab61 | |||
a883948d56 | |||
da86d3303f | |||
2f3138208a | |||
e688e10684 | |||
66b62df70b | |||
a4dd45e6a6 | |||
b4439b7d78 | |||
bf566b7bb7 | |||
2c897a1b18 | |||
a5cfbb3181 | |||
119a630643 | |||
c6fe6b5e3e | |||
6ff43b11b1 | |||
f35655028e | |||
709af3e92b | |||
5ec9dce507 | |||
1e81638433 | |||
145b613f5d | |||
a243c3f577 | |||
75b9ba907f | |||
1a368ac4d4 | |||
2833e8ba23 | |||
de3837f777 | |||
8dc5eaca52 | |||
5c41de0c9d | |||
7a258f022f | |||
cbdf1c3ccf | |||
dcab2404fa | |||
05e80094de | |||
9fab994dec | |||
6eddd1ccd3 | |||
211fb824a1 | |||
17c7b97abe | |||
ac3c539d45 | |||
c1e94e661f | |||
8a6ee5905a | |||
36c636474d | |||
0bff122b1c | |||
2ffb9bb574 | |||
6190d00644 | |||
67673a55f7 | |||
4448509d92 | |||
8482961f7f | |||
552f8cc4ef | |||
21b32633c5 | |||
8863132e8e | |||
f72b556d92 | |||
4b7e0a19eb | |||
3db92322ba | |||
aac668dfca | |||
0636dcf19d | |||
3fca56e819 | |||
6124d8c493 | |||
9ef99a3aa9 | |||
66fc47edae | |||
af948e76f3 | |||
dfd98efe8b | |||
a8de833c43 | |||
3bff748fbe | |||
8adf66512b | |||
d4313e6f95 | |||
24fdfc9aef | |||
e5f454bac3 | |||
ca9ce5ed16 | |||
2087622bd6 | |||
a651e27a20 | |||
0fd0b6e1fd | |||
c63569d592 | |||
7b7d00c642 | |||
9e27889f91 | |||
8bbb1966a4 | |||
5d00e295fd | |||
0d4a2b65a0 | |||
2ba53649bd | |||
31f4af721d | |||
ce734ba783 | |||
77196746b3 | |||
99650ab732 | |||
96b7d4a15d | |||
ce1aff1557 | |||
9d5949e9d1 | |||
3ca681a4a6 | |||
49449e42be | |||
dad3167f16 | |||
3cc632fbbb | |||
f780d47198 | |||
e0bd6b9d4e | |||
5cf0f7030d | |||
77b763a88f | |||
f9390ceb10 | |||
bc41ff0540 | |||
54e91eb074 | |||
a42a1a97ba | |||
f3d5e26e1e | |||
99eae385b8 | |||
f6d67b94f3 | |||
2d9bb2130e | |||
10fff8f0f5 | |||
8ee994ce7d | |||
86aed2185d | |||
64ec151e4b | |||
3e4e661b28 | |||
7713ca31e5 | |||
b0da1b4811 | |||
0f302d30ec | |||
140e851fcd | |||
b9b4343fd5 | |||
eff49beb36 | |||
952d74207d | |||
853d146457 | |||
97d6a39677 | |||
095a597381 | |||
a66aec6965 | |||
03a3ef38e1 | |||
464d706920 | |||
61dd5acb80 | |||
1e5cf8655e | |||
f096f4f55f | |||
85442c4634 | |||
a357405f3a | |||
6070ee0f83 | |||
f8721a324d | |||
ca861a6349 | |||
493c6576c3 | |||
a8765af589 | |||
ca8484b466 | |||
7a8d746a54 | |||
1cffcd9b9e | |||
b6c041c9b5 | |||
47b35db03e | |||
78985ff633 | |||
93a5380c09 | |||
6677f1faf5 | |||
e7b32e941a | |||
5abd240d50 | |||
759c2d4a6f | |||
92772952fd | |||
2f53cbf088 | |||
c3b74a869a | |||
841d1927a9 | |||
06c450e9a5 | |||
67de638c76 | |||
c90b8eef97 | |||
6ad4598e7e | |||
fd580083d5 | |||
a0003c5f13 | |||
8291c96e69 | |||
561325e66d | |||
a840f39a91 | |||
64f9b50e40 | |||
0273d2e02c | |||
daf3b980ef | |||
6e36cd139a | |||
9ca76348ff | |||
58a5725ad2 | |||
116c3c787c | |||
74a896b3cd | |||
e2ebac27ea | |||
b799f3a46d | |||
3a3cfbc85e | |||
161b9454c2 | |||
b83b7145af | |||
26c4e466bd | |||
42f752e400 | |||
0b67a40d57 | |||
69ab9788fc | |||
7972187b77 | |||
a809847d60 | |||
203285bab9 | |||
52c7a098cc | |||
75bc937995 | |||
dd41145912 | |||
0983bf02e2 | |||
0deb59b6e2 | |||
fdc9fd67d8 | |||
01eb4b473d | |||
4ff42c11e6 | |||
85d82ab9ca | |||
dc6cde2cf1 | |||
ea1c1bb8d4 | |||
c6eca9f895 | |||
e71f622453 | |||
b6266878d4 | |||
4907fccf48 | |||
f4b84941cd | |||
c2df87bcc6 | |||
79f33c749b | |||
fd316167d8 | |||
f60d857c93 | |||
31628cfdcb | |||
4d42f74c0c | |||
13729ec4b6 | |||
8dc4c0871a | |||
207e080b9e | |||
39fe63fb2d | |||
24c2ffefc9 | |||
c293a1742d | |||
cb46756d31 | |||
332e731023 | |||
f9263975bc | |||
67ebf7aa19 | |||
2b52d5edbc | |||
948e6ea6f8 | |||
ca9247fb19 | |||
73455b4264 | |||
28b0793fc9 | |||
c904726259 | |||
6606b65c9b | |||
61160fd2f5 | |||
bf71f9ea16 | |||
fe751fdb23 | |||
947f91d570 | |||
c5d4e30e24 | |||
f560aa7523 | |||
6bcfb2dd51 | |||
bf062124f7 | |||
221666f59a | |||
4369a2d161 | |||
cd6ee4ef5e | |||
872b17cf24 | |||
88e11347bc | |||
a3dd489c70 | |||
0c1c108b2b | |||
f02ed43f33 | |||
63c3d7ceee | |||
dac45a884e | |||
ec589c2639 | |||
f65e777d1b | |||
684ac9fa24 | |||
330cbc6a68 | |||
14bfca8c3a | |||
20c07d31b2 | |||
64b4f67477 | |||
a8ceadc300 | |||
973d25f467 | |||
0d06701e2f | |||
379f1cc217 | |||
7b7ae4ff89 | |||
8e83a401eb | |||
2d1891a182 | |||
8df066df12 | |||
bd59f95e1a | |||
2b982a1c0c | |||
ab64fbc904 | |||
733b98f072 | |||
7c538a3658 | |||
8298ba5765 | |||
33a23773d8 | |||
21a3b82845 | |||
8688eb5da0 | |||
5b0ea9673f | |||
44fd8adeba | |||
a5e03d55c3 | |||
80629322ea | |||
946efbcb7f | |||
be8a314d2b | |||
0a7203cafe | |||
786fed0151 | |||
9cd8228a20 | |||
652b5f22dd | |||
eed3c06789 | |||
3b283d4a98 | |||
bc6b5ba7b3 | |||
74789ae88f | |||
295d6dee74 | |||
5010a1e312 | |||
3c2f7ea622 | |||
94f02f0ad8 | |||
375f84b24e | |||
06c649dfd0 | |||
71eca70a22 | |||
53c7bc622c | |||
975ae45e49 | |||
e7c68c1a5c | |||
5beeb78220 | |||
c90ba7aa0f | |||
802ccc1b9a | |||
b6ef251625 | |||
fd707d6a07 | |||
392cd8569f | |||
e32eda26d9 | |||
d8aaccf80c | |||
d5fd5f5f2d | |||
2cb69c12f1 | |||
7c75346a1a | |||
148d15b6d9 | |||
a46a79df59 | |||
e350f9b335 | |||
bd00773f1b | |||
ef3c7f0fd6 | |||
f4f44f978e | |||
442416efc3 | |||
ef33ffedcf | |||
430d4aeaa7 | |||
171632f83f | |||
1fa7141b58 | |||
916cc36430 | |||
27b877dd33 | |||
5cbe1c410f | |||
7846af390e | |||
79d9ebc805 | |||
25b853c535 | |||
a93141343f | |||
9a467c5ecd | |||
70be2ae596 | |||
36eb0a108e | |||
0bf6fb1739 | |||
892adf4c47 | |||
5d1d004b72 | |||
dea5a60b2d | |||
652a1b7650 | |||
350843af1e | |||
e04c4a8ee3 | |||
9d0c3f7535 | |||
9561d4da2e | |||
8296dcf946 | |||
e62e8b88c2 | |||
4388a248b9 | |||
f9cf0aaf23 | |||
dc9ee09838 | |||
7cb27283c5 | |||
10a9840b34 | |||
ce3e04bfe8 | |||
52f93f8f12 | |||
af9e1a122d | |||
9017b8ec11 | |||
bf4f687a2a | |||
9d4e6eb825 | |||
fba4afb7d2 | |||
8c74f784f7 | |||
69ca1ffa59 | |||
7d1b00877e | |||
1a48fed1f7 | |||
bc86359e63 | |||
f6822f1502 | |||
398c34d842 | |||
72a893be95 | |||
7b23b0e103 | |||
0ce7878042 | |||
da8483e6a6 | |||
16f70fd946 | |||
78aa898b37 | |||
b7f94a222d | |||
7bea2c26b8 | |||
7c178b8095 | |||
865f085094 | |||
28fe69fe94 | |||
232cf8d426 | |||
22e74983b0 | |||
c88dd2257a | |||
439d8d396f | |||
6d8086c09b | |||
e85f252f29 | |||
4b818ad51c | |||
c2518448a3 | |||
e7a8deed05 | |||
0ac599d20c | |||
7d7074e6b7 | |||
35ca34d07d | |||
90d7316b4c | |||
904b4e96d9 | |||
2c46c59a79 | |||
297ff86895 | |||
a154401424 | |||
ad2713fc00 | |||
6388cfaf40 | |||
167f38e342 | |||
919b3c3435 | |||
2e1ab22173 | |||
0a23563d7e | |||
37e4ec6364 | |||
6a8b947c2e | |||
a16ac37625 | |||
cf4c7826b2 | |||
a0a26f0a1e | |||
a921139a12 | |||
36da7b66c8 | |||
3aa87544eb | |||
6121fa505e | |||
a5ba5befd1 | |||
b7214a306c | |||
d7616e941a | |||
834a2f1e4d | |||
0e5f2fe748 | |||
e0bcb5e0b9 | |||
59d4890eae | |||
51da5360da | |||
2655aef28b | |||
45d3a7a124 | |||
662e4f8940 | |||
c06993cb8e | |||
a650f30ce8 | |||
0a924b2dcb | |||
89f62683ce | |||
143d88f3df | |||
d166a65422 | |||
dd268993b3 | |||
13a35b288f | |||
81e653d31b | |||
875ec8b8bd | |||
989df9b857 | |||
0829d3c176 | |||
ce64889b04 | |||
d3a0bfc5f6 | |||
e965c603d2 | |||
0e2fb8c96c | |||
2db1d84d3c | |||
12a1916007 | |||
b4526e9895 | |||
a2d867c860 | |||
05b1c37379 | |||
906cfe9268 | |||
3c8054faa7 | |||
c6c9046826 | |||
2bbbbf6fdd | |||
9cce4001af | |||
2e944cf2f4 | |||
2b0143775c | |||
49fec7d8f2 | |||
ca1ac2bb83 | |||
50b1a7e6b0 | |||
69ce2c0473 | |||
a3b446dbe7 | |||
1032d9927f | |||
12e8a50abc | |||
a4142097f8 | |||
b388ccb6f3 | |||
e011502b7e | |||
4f167cb836 | |||
9455d438e2 | |||
a356ecf9b6 | |||
066ac591ac | |||
62f006b89a | |||
ee75ff2753 | |||
e4c9defb70 | |||
bb102c1918 | |||
24ebe2946c | |||
ba82b1fa27 | |||
e3b145e7b7 | |||
242c3731ee | |||
5f7eee8eac | |||
1833f6ff0a | |||
e5fb954645 | |||
13f76dc020 | |||
b409bdcc73 | |||
8c3cb3f585 | |||
76a8b4df50 | |||
a03680311d | |||
6ee36cb5c7 | |||
5625326c65 | |||
b912419839 | |||
fe01ead023 | |||
229c105d0c | |||
b6e044345f | |||
d9906121e1 | |||
3e019f7f34 | |||
eb34cb6f27 | |||
3a3178bcb9 | |||
cdf6580ecc | |||
c42bc74f1f | |||
35fd79f577 | |||
4ef0682e5a | |||
d0b7047189 | |||
ae3f936b66 | |||
1ef492809b | |||
5bf9dd3a9d | |||
b18a66f66b | |||
1dadfdc699 | |||
14a3f51b73 | |||
96116aeaec | |||
7fd31b6a64 | |||
299bc0db13 | |||
4b9ccae442 | |||
079ce552e3 | |||
163684e3a9 | |||
f698f561c9 | |||
cb207f18a5 | |||
76a5cdc977 | |||
a82af1d2d1 | |||
ac7d51ad80 | |||
797a739c92 | |||
666b59b463 | |||
a83d9a070c | |||
7637377471 | |||
6515f88d92 | |||
92534b9c82 | |||
c12360daa8 | |||
3d28118f3e | |||
04adfde064 | |||
d8aabfd448 | |||
cf95870d9d | |||
55f8876bcc | |||
9fb66186f0 | |||
da8fe99ca4 | |||
20374fde36 | |||
5131f722a7 | |||
1ef0a1028f | |||
0fd1f04eda | |||
5c0ba5d06c | |||
d9532b6fa0 | |||
b96065514f | |||
0e9b8e4140 | |||
d1c773360f | |||
74538bba8d | |||
64c95e3811 | |||
33fd70291a | |||
0cb4bc951a | |||
3761ab9610 | |||
8c29bba108 | |||
4e41261237 | |||
77529ef3b1 | |||
0ba96adbbc | |||
7df277c0bc | |||
c94f7b10bd | |||
83a76f7d6f | |||
6c988241eb | |||
29145dfc2d | |||
4b74e8ec70 | |||
612012aff8 | |||
6ab60d0ccd | |||
6daed83d88 | |||
f25442c036 | |||
ffffd447f2 | |||
4b511c47f0 | |||
158d471a98 | |||
107a90395c | |||
ce5fd53822 | |||
810ca78215 | |||
eb945b3315 | |||
34f24fe331 | |||
743392017d | |||
15b877f005 | |||
0653769156 | |||
3ed319872a | |||
ee124671d8 | |||
1b4dabd37c | |||
fdd253f042 | |||
1a15fdd2f0 | |||
2c66280b3f | |||
778c39d947 | |||
fa15addfb2 | |||
afbb9474b7 | |||
0acb4f8cb1 | |||
08de0938a0 | |||
2c9b80c177 | |||
e8c19df8c9 | |||
7681003512 | |||
dba8db19cb | |||
d199cdf088 | |||
f2840c5ca4 | |||
1c7a0ba4e1 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
||||
* @pdcastro @thgreasi @CameronDiver @hedss
|
11
.github/ISSUE_TEMPLATE.md
vendored
11
.github/ISSUE_TEMPLATE.md
vendored
@ -1,2 +1,11 @@
|
||||
- **resin-cli version:**
|
||||
- **Cli version:**
|
||||
- **Standalone install:** yes/no
|
||||
- **Node version:**
|
||||
- **Npm version:**
|
||||
- **Operating system and architecture:**
|
||||
|
||||
---
|
||||
|
||||
*Please keep in mind that we try to use the issue tracker of this repository for specific bug reports & CLI feature requests. General & troubleshooting questions are encouraged to be posted to the [balena forums](https://forums.balena.io) where the community can both contribute and benefit from the answers.*
|
||||
|
||||
*Before submitting this issue please check that this issue is not a duplicate. If there is another issue describing the same problem or feature please add your information to the existing issue's comments.*
|
||||
|
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
<!-- You can remove tags that do not apply. -->
|
||||
Resolves: # <!-- Refer an issue of this repository that this PR fixes -->
|
||||
See: <url> <!-- Refer to any external resource, like a PR, document or discussion -->
|
||||
Depends-on: <url> <!-- This change depends on a PR to get merged/deployed first -->
|
||||
Change-type: major|minor|patch <!-- The change type of this PR -->
|
||||
|
||||
---
|
||||
##### Contributor checklist
|
||||
<!-- For completed items, change [ ] to [x]. -->
|
||||
- [ ] Introduces security considerations
|
||||
- [ ] Affects the development, build or deployment processes of the component
|
12
.gitignore
vendored
12
.gitignore
vendored
@ -27,11 +27,19 @@ node_modules
|
||||
npm-shrinkwrap.json
|
||||
package-lock.json
|
||||
.resinconf
|
||||
.balenaconf
|
||||
resinrc.yml
|
||||
balenarc.yml
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
.idea
|
||||
.nvmrc
|
||||
.vscode
|
||||
|
||||
/tmp
|
||||
build/
|
||||
build-bin/
|
||||
build-zip/
|
||||
|
||||
# Ignore fast-boot cache file
|
||||
/bin/.fast-boot.json
|
||||
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"useTabs": true
|
||||
}
|
36
.resinci.yml
Normal file
36
.resinci.yml
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
npm:
|
||||
platforms:
|
||||
- name: linux
|
||||
os: alpine
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "8"
|
||||
- "10"
|
||||
- name: linux
|
||||
os: alpine
|
||||
architecture: x86
|
||||
node_versions:
|
||||
- "8"
|
||||
- "10"
|
||||
- name: darwin
|
||||
os: macos
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "8"
|
||||
- "10"
|
||||
- name: windows
|
||||
os: windows
|
||||
architecture: x86_64
|
||||
node_versions:
|
||||
- "8"
|
||||
- "10"
|
||||
- name: windows
|
||||
os: windows
|
||||
architecture: x86
|
||||
node_versions:
|
||||
- "8"
|
||||
- "10"
|
||||
|
||||
docker:
|
||||
publish: false
|
27
.travis.yml
27
.travis.yml
@ -1,22 +1,19 @@
|
||||
language: node_js
|
||||
matrix:
|
||||
include:
|
||||
- node_js:
|
||||
- '6'
|
||||
env:
|
||||
- CAN_DEPLOY=true
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
node_js:
|
||||
- "6"
|
||||
before_install:
|
||||
- npm -g install npm@4
|
||||
script: npm run ci
|
||||
notifications:
|
||||
email: false
|
||||
deploy:
|
||||
provider: npm
|
||||
email: accounts@resin.io
|
||||
api_key:
|
||||
secure: phet6Du13hc1bzStbmpwy2ODNL5BFwjAmnpJ5wMcbWfI7fl0OtQ61s2+vW5hJAvm9fiRLOfiGAEiqOOtoupShZ1X8BNkC708d8+V+iZMoFh3+j6wAEz+N1sVq471PywlOuLAscOcqQNp92giCVt+4VPx2WQYh06nLsunvysGmUM=
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
condition: "$CAN_DEPLOY = 'true' && $TRAVIS_TAG =~ ^v?[[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+"
|
||||
repo: resin-io/resin-cli
|
||||
- provider: script
|
||||
script: npm run release
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
condition: "$TRAVIS_TAG =~ ^v?[[:digit:]]+\\.[[:digit:]]+\\.[[:digit:]]+"
|
||||
repo: balena-io/balena-cli
|
||||
|
783
CHANGELOG.md
783
CHANGELOG.md
@ -4,6 +4,789 @@ All notable changes to this project will be documented in this file
|
||||
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## 10.13.5 - 2019-05-21
|
||||
|
||||
* Rename `preload --commit latest` to `preload --commit current` [Alexis Svinartchouk]
|
||||
* Update balena-preload to 8.1.4 [Alexis Svinartchouk]
|
||||
|
||||
## 10.13.4 - 2019-05-20
|
||||
|
||||
* Fix TypeError when running 'balena apps' [Paulo Castro]
|
||||
|
||||
## 10.13.3 - 2019-05-17
|
||||
|
||||
* Apps: Fix the device count columns being empty [Thodoris Greasidis]
|
||||
|
||||
## 10.13.2 - 2019-05-17
|
||||
|
||||
* Remove intermediate containers when doing a local push [Cameron Diver]
|
||||
|
||||
## 10.13.1 - 2019-05-16
|
||||
|
||||
* Docs: Fix os configure example in os build-config docs [Thodoris Greasidis]
|
||||
|
||||
## 10.13.0 - 2019-05-15
|
||||
|
||||
* Use the open-balena-api endpoints for device type & version info [Thodoris Greasidis]
|
||||
|
||||
## 10.12.1 - 2019-05-15
|
||||
|
||||
* Preload: bump version to fix preloading on logstream supervisors [Gergely Imreh]
|
||||
|
||||
## 10.12.0 - 2019-05-15
|
||||
|
||||
* Fix video url [Daniel Andrade]
|
||||
|
||||
## 10.11.1 - 2019-05-15
|
||||
|
||||
* Debounce livepush invocations to collect changes together [Cameron Diver]
|
||||
|
||||
## 10.11.0 - 2019-05-15
|
||||
|
||||
* Update balena-cli video url [Daniel Andrade]
|
||||
|
||||
## 10.10.5 - 2019-05-14
|
||||
|
||||
* Update instructions for adding folder to path in MacOS [Jasmine Gilbert]
|
||||
|
||||
## 10.10.4 - 2019-05-14
|
||||
|
||||
* Pin pkg version to avoid node 6 error [Cameron Diver]
|
||||
* Use TCP keepalive probes to detect local log stream closing [Cameron Diver]
|
||||
|
||||
## 10.10.3 - 2019-05-10
|
||||
|
||||
* Fix 'local configure' on macOS standalone installation [Paulo Castro]
|
||||
|
||||
## 10.10.2 - 2019-05-10
|
||||
|
||||
* Update dependencies including a balena-preload fix for lots of releases [Pagan Gazzard]
|
||||
|
||||
## 10.10.1 - 2019-05-04
|
||||
|
||||
* Replace 'npm' upgrade notifier message with INSTALL.md URL [Paulo Castro]
|
||||
|
||||
## 10.10.0 - 2019-05-03
|
||||
|
||||
* Qemu: use v4.0.0-balena [Gergely Imreh]
|
||||
|
||||
## 10.9.4 - 2019-05-02
|
||||
|
||||
* Improve livepush UX [Cameron Diver]
|
||||
* Improve logging for detached mode + livepush [Cameron Diver]
|
||||
|
||||
## 10.9.3 - 2019-05-02
|
||||
|
||||
* Actions/auth: fix mixed indentation error [Gergely Imreh]
|
||||
* Dependencies: bump gulp to v4 [Gergely Imreh]
|
||||
* Dependencies: bump etcher-sdk to pull in fixes [Gergely Imreh]
|
||||
|
||||
## 10.9.2 - 2019-05-01
|
||||
|
||||
* Update README and INSTALL docs (review typos and some rewording) [Paulo Castro]
|
||||
|
||||
## 10.9.1 - 2019-05-01
|
||||
|
||||
* Allow any amount of subdomains when parsing .local addresses [Cameron Diver]
|
||||
|
||||
## 10.9.0 - 2019-04-29
|
||||
|
||||
* Unify the CLI instructions between capitanodoc.ts and README.md, move the installation instructions to INSTALL.md, and update the markdown generation scripts. [Paulo Castro]
|
||||
|
||||
## 10.8.2 - 2019-04-26
|
||||
|
||||
* Handle app names that look like a number (eg 1234) [Paulo Castro]
|
||||
|
||||
## 10.8.1 - 2019-04-26
|
||||
|
||||
* Add better semantics for detached mode + live for push [Cameron Diver]
|
||||
|
||||
## 10.8.0 - 2019-04-25
|
||||
|
||||
* Allow specifying a .local address for logs and push [Cameron Diver]
|
||||
|
||||
## 10.7.0 - 2019-04-24
|
||||
|
||||
* Allow filtering of system logs with push and logs commands [Cameron Diver]
|
||||
|
||||
## 10.6.0 - 2019-04-24
|
||||
|
||||
* Add per-service filtering to logs and push [Cameron Diver]
|
||||
|
||||
## 10.5.0 - 2019-04-24
|
||||
|
||||
* Push: Add detached flag to avoid streaming logs after local push [Cameron Diver]
|
||||
|
||||
## 10.4.1 - 2019-04-24
|
||||
|
||||
* Add ability to use balena logs with a local mode device [Cameron Diver]
|
||||
* Refactor: Convert logs action to typescript [Cameron Diver]
|
||||
* Logs: Make device logs consistent across the CLI [Cameron Diver]
|
||||
* Refactor: Create and use validation functions for input [Cameron Diver]
|
||||
|
||||
## 10.4.0 - 2019-04-24
|
||||
|
||||
* Tunnel: allow using partial device uuids [Will Boyce]
|
||||
|
||||
## 10.3.0 - 2019-04-23
|
||||
|
||||
* Add --dockerfile option to the build, deploy and push commands [Paulo Castro]
|
||||
* Fix push and deploy issues under Windows ('/' vs '\' path separators) [Paulo Castro]
|
||||
|
||||
## 10.2.0 - 2019-04-23
|
||||
|
||||
* Update livepush documentation and required versions [Cameron Diver]
|
||||
* Cleanup intermediate containers on exit of livepush [Cameron Diver]
|
||||
* Livepush: Perform full rebuild on Dockerfile-like file change [Cameron Diver]
|
||||
* Add livepush ability to balena push [Cameron Diver]
|
||||
* Log: Add livepush logging functions [Cameron Diver]
|
||||
* Add device status endpoint api function [Cameron Diver]
|
||||
|
||||
## 10.1.1 - 2019-04-22
|
||||
|
||||
* Ensure not marking successful releases as canceled [Thodoris Greasidis]
|
||||
|
||||
## 10.1.0 - 2019-04-18
|
||||
|
||||
* Updated CLI installation notes on README.md and ran prettier [Paulo Castro]
|
||||
* Add more information about the stantalone version [Daniel Andrade]
|
||||
|
||||
## 10.0.1 - 2019-04-13
|
||||
|
||||
* Fix docs markdown (deprecation messages for 'local push' and 'sync') [Paulo Castro]
|
||||
* Fix file ignore rules matching metadata folders breaking qemu builds [Akis Kesoglou]
|
||||
* Remove information about livepush in sync deprecation message [Cameron Diver]
|
||||
|
||||
## 10.0.0 - 2019-04-03
|
||||
|
||||
* Remove 'quickstart' command and deprecate 'local push'. [Paulo Castro]
|
||||
|
||||
## 9.15.6 - 2019-03-28
|
||||
|
||||
* Support nocache flag in push <ip> [Cameron Diver]
|
||||
|
||||
## 9.15.5 - 2019-03-27
|
||||
|
||||
* Bump docker-progress (4.0.0) to improve `balena deploy` error handling. [Paulo Castro]
|
||||
|
||||
## 9.15.4 - 2019-03-25
|
||||
|
||||
* Update resin-compose-parse to v2.0.4 [Pablo Carranza Velez]
|
||||
* Update resin-multibuild to v2.1.5 [Pablo Carranza Velez]
|
||||
|
||||
## 9.15.3 - 2019-03-25
|
||||
|
||||
* Allow 'balena push <deviceIpAddress>' when not logged in to balenaCloud. [Paulo Castro]
|
||||
|
||||
## 9.15.2 - 2019-03-18
|
||||
|
||||
* Bump resin-multibuild (2.1.4), docker-progress (3.0.5), resin-lint (3.0.1) [Paulo Castro]
|
||||
|
||||
## 9.15.1 - 2019-03-12
|
||||
|
||||
* Add maintainer, reviewers, and devexp team as code owners [Gergely Imreh]
|
||||
|
||||
## 9.15.0 - 2019-03-12
|
||||
|
||||
* Ssh: add `--noninteractive` flag not to suggest devices to connect to [Gergely Imreh]
|
||||
|
||||
## 9.14.7 - 2019-03-11
|
||||
|
||||
* Ssh: correct the minimum OS version that allows host OS connection [Gergely Imreh]
|
||||
|
||||
## 9.14.6 - 2019-03-07
|
||||
|
||||
* Fix 'unauthorized' error for additional members of legacy apps [Paulo Castro]
|
||||
|
||||
## 9.14.5 - 2019-03-05
|
||||
|
||||
* Retry image push a few times (balena deploy, 'unknown blob') [Paulo Castro]
|
||||
* Harden 'remote-build' error handling (balena push) [Paulo Castro]
|
||||
|
||||
## 9.14.4 - 2019-03-05
|
||||
|
||||
* Update resin-multibuild to pick up fixes [Cameron Diver]
|
||||
|
||||
## 9.14.3 - 2019-03-04
|
||||
|
||||
* Minor doc updates (add DEBUG hint) [Paulo Castro]
|
||||
|
||||
## 9.14.2 - 2019-03-01
|
||||
|
||||
* Bump resin-multibuild version to fix docker-compose 'context' issue [Paulo Castro]
|
||||
|
||||
## 9.14.1 - 2019-02-27
|
||||
|
||||
* Add registry-secrets help msg for build and deploy commands [Paulo Castro]
|
||||
|
||||
## 9.14.0 - 2019-02-27
|
||||
|
||||
* Extend private registry support to balena build and deploy commands [Paulo Castro]
|
||||
|
||||
## 9.13.0 - 2019-02-27
|
||||
|
||||
* Integrate new resin-multibuild major version (private docker registry authentication support for the docker-compose 'image' instruction). [Paulo Castro]
|
||||
|
||||
## 9.12.7 - 2019-02-26
|
||||
|
||||
* Fix parsing of not-really-numeric device UUID parameters [Paulo Castro]
|
||||
|
||||
## 9.12.6 - 2019-02-22
|
||||
|
||||
* Fix regression in ee75ff and restore functionality to ToC in docs [Chris Crocker-White]
|
||||
|
||||
## 9.12.5 - 2019-02-22
|
||||
|
||||
* Re-run newest prettier on master [Thodoris Greasidis]
|
||||
|
||||
## 9.12.4 - 2019-02-20
|
||||
|
||||
* Tunnel: Refactor to improve log output [Rich Bayliss]
|
||||
|
||||
## 9.12.3 - 2019-02-15
|
||||
|
||||
* Tunnel: Add the tunnel command [Rich Bayliss]
|
||||
|
||||
## 9.12.2 - 2019-02-07
|
||||
|
||||
* Remove fetching of Mixpanel token [Paulo Castro]
|
||||
|
||||
## 9.12.1 - 2019-02-05
|
||||
|
||||
* Rename localcommit to localrelease in target state for local mode [Cameron Diver]
|
||||
|
||||
## 9.12.0 - 2019-01-22
|
||||
|
||||
* Don't offer to disable automatic application updates when using pinning [Alexis Svinartchouk]
|
||||
|
||||
## 9.11.2 - 2019-01-17
|
||||
|
||||
* Update 'balena help local flash' documentation re zipped images [Paulo Castro]
|
||||
|
||||
## 9.11.1 - 2019-01-16
|
||||
|
||||
* Typescript: Add TypeScript migration notice to README file [Paulo Castro]
|
||||
|
||||
## 9.11.0 - 2019-01-16
|
||||
|
||||
* Support compressed images in `balena local flash` [Alexis Svinartchouk]
|
||||
|
||||
## 9.10.1 - 2019-01-15
|
||||
|
||||
* Fix up small docs typo [Matthew McGinn]
|
||||
|
||||
## 9.10.0 - 2019-01-14
|
||||
|
||||
* Improve startup time by adding fast-boot [Shaun Mulligan]
|
||||
|
||||
## 9.9.4 - 2019-01-13
|
||||
|
||||
* Lazy load the sdk as much as possible [Pagan Gazzard]
|
||||
|
||||
## 9.9.3 - 2019-01-13
|
||||
|
||||
* Lazy-load docker-toolbelt [Pagan Gazzard]
|
||||
|
||||
## 9.9.2 - 2019-01-11
|
||||
|
||||
* Lazy-load etcher-sdk to speed up startup [Pagan Gazzard]
|
||||
* Lazy-load resin-cli-form and resin-cli-visuals to speed up startup [Pagan Gazzard]
|
||||
|
||||
## 9.9.1 - 2019-01-11
|
||||
|
||||
* Update util available-drives action [Alexis Svinartchouk]
|
||||
* Update lib/actions/local/flash.coffee [Alexis Svinartchouk]
|
||||
|
||||
## 9.9.0 - 2019-01-10
|
||||
|
||||
* Request access to previously pushed release via `balena deploy` [Matthew McGinn]
|
||||
|
||||
## 9.8.0 - 2019-01-01
|
||||
|
||||
* Escape backticks in JS template literal [Trevor Sullivan]
|
||||
* Moving docs from PR #1055 [Trevor Sullivan]
|
||||
|
||||
## 9.7.0 - 2018-12-28
|
||||
|
||||
* Added documentation about the dependencies required to build balena-cli [Trevor Sullivan]
|
||||
|
||||
## 9.6.0 - 2018-12-18
|
||||
|
||||
* Add push --registry-secrets option for private docker registry authentication [Paulo Castro]
|
||||
|
||||
## 9.5.0 - 2018-12-14
|
||||
|
||||
* Add explicit device type option to `os configure` & `config generate` [Thodoris Greasidis]
|
||||
|
||||
## 9.4.1 - 2018-12-14
|
||||
|
||||
* Fix deploy action on node 6 [Alexis Svinartchouk]
|
||||
|
||||
## 9.4.0 - 2018-12-10
|
||||
|
||||
* Actions: Add resource tag operations [Thodoris Greasidis]
|
||||
|
||||
## 9.3.6 - 2018-12-03
|
||||
|
||||
* Make architecture checking more stringent when installing emulators [Cameron Diver]
|
||||
|
||||
## 9.3.5 - 2018-11-28
|
||||
|
||||
* Fix potential dependency security issue [Thodoris Greasidis]
|
||||
|
||||
## 9.3.4 - 2018-11-28
|
||||
|
||||
* Update dependencies [Cameron Diver]
|
||||
|
||||
## 9.3.3 - 2018-11-27
|
||||
|
||||
* Actions/os-initialize: Convert Promise to a Bluebird one [Thodoris Greasidis]
|
||||
|
||||
## 9.3.2 - 2018-11-26
|
||||
|
||||
* Actions/os: Fix os configure using bluebird methods on plain promise [Thodoris Greasidis]
|
||||
|
||||
## 9.3.1 - 2018-11-23
|
||||
|
||||
* Actions/config: Fix examples to include --version as required [Thodoris Greasidis]
|
||||
|
||||
## 9.3.0 - 2018-11-22
|
||||
|
||||
* Stop pinning ext2fs and update preload to ^8.0.4 [Alexis Svinartchouk]
|
||||
|
||||
## 9.2.2 - 2018-11-20
|
||||
|
||||
* Fix missing import in `os configure` [Akis Kesoglou]
|
||||
|
||||
## 9.2.1 - 2018-11-19
|
||||
|
||||
* Add case-insensitive checking for application names in balena push [Cameron Diver]
|
||||
|
||||
## 9.2.0 - 2018-11-16
|
||||
|
||||
* Make specifying the version during configuration optional [Akis Kesoglou]
|
||||
|
||||
## 9.1.4 - 2018-11-16
|
||||
|
||||
* Extend the github repository templates [Thodoris Greasidis]
|
||||
|
||||
## 9.1.3 - 2018-11-16
|
||||
|
||||
* Include `join` and `leave` commands in API documentation [Akis Kesoglou]
|
||||
|
||||
## 9.1.2 - 2018-11-13
|
||||
|
||||
* Fix build & deploy commands in standalone build [Tim Perry]
|
||||
|
||||
## 9.1.1 - 2018-11-12
|
||||
|
||||
* Fix the architecture string used when downloading qemu versions [Cameron Diver]
|
||||
|
||||
## 9.1.0 - 2018-11-07
|
||||
|
||||
* Update qemu to v3, and automatically use the correct architecture (arm/aarch64) [Edward Keeble]
|
||||
|
||||
## 9.0.3 - 2018-11-07
|
||||
|
||||
* Mark api keys in the CLI as non-experimental [Tim Perry]
|
||||
|
||||
## 9.0.2 - 2018-11-06
|
||||
|
||||
* Stop validating device config, now that it's API-generated [Tim Perry]
|
||||
|
||||
## v9.0.1 - 2018-11-01
|
||||
|
||||
* Dont ignore balena metadata directories when balena pushing [Cameron Diver]
|
||||
|
||||
## v9.0.0 - 2018-10-29
|
||||
|
||||
* Change env var commands to set app-wide env vars, using the new SDK [Tim Perry]
|
||||
* Utils/promote: Use the application slug for filtering & presenting [Thodoris Greasidis]
|
||||
* Rename everything from 'resin' to 'balena' [Tim Perry]
|
||||
* Sort device types by name [Thodoris Greasidis]
|
||||
* Utils/promote: Do not rely on the user to always be there [Thodoris Greasidis]
|
||||
* Add support for the Opensource provisioning flow [Thodoris Greasidis]
|
||||
* Add rename notice to resin-cli [Paulo Castro]
|
||||
|
||||
## v8.1.0 - 2018-10-24
|
||||
|
||||
* README: Add development guidelines section [Thodoris Greasidis]
|
||||
* Chore: Add on the fly transpiled bin [Thodoris Greasidis]
|
||||
|
||||
## v8.0.3 - 2018-10-22
|
||||
|
||||
* Ssh: Move from resin-sdk-preconfigured to resin-sdk [Will Boyce]
|
||||
|
||||
## v8.0.2 - 2018-10-20
|
||||
|
||||
* Utils/patterns: Drop resin-sdk-preconfigured [Thodoris Greasidis]
|
||||
* Actions/app: Drop resin-sdk-preconfigured [Thodoris Greasidis]
|
||||
|
||||
## v8.0.1 - 2018-10-20
|
||||
|
||||
* Update dockerignore to fix escSL bug [Tim Perry]
|
||||
|
||||
## v8.0.0 - 2018-10-19
|
||||
|
||||
* Support multicontainer local mode in resin push [Cameron Diver]
|
||||
* Stop accepting resin-compose.yml as a build composition definition [Cameron Diver]
|
||||
* Send push source packages as gzipped data [Cameron Diver]
|
||||
* Respect ignore files when tarring sources [Cameron Diver]
|
||||
* Check for correct architecture when preloading, instead of correct device type [Alexis Svinartchouk]
|
||||
* Default preload boolean parameters to false [Alexis Svinartchouk]
|
||||
* Correctly error out on failed remote builds [Cameron Diver]
|
||||
* Bump tsconfig target to es6 [Cameron Diver]
|
||||
|
||||
## v7.10.9 - 2018-10-18
|
||||
|
||||
* Update sdk references in wizzard.coffee [Scott Lowe]
|
||||
|
||||
## v7.10.8 - 2018-10-18
|
||||
|
||||
* Update sdk references in notes.coffee [Scott Lowe]
|
||||
* Update sdk references in device.coffee [Scott Lowe]
|
||||
|
||||
## v7.10.7 - 2018-10-18
|
||||
|
||||
* Update sdk sdk references in auth.coffee [Scott Lowe]
|
||||
|
||||
## v7.10.6 - 2018-10-03
|
||||
|
||||
* Fix formatting of preload examples [Tim Perry]
|
||||
|
||||
## v7.10.5 - 2018-09-25
|
||||
|
||||
* README: Fix typo [Lucian Buzzo]
|
||||
|
||||
## v7.10.4 - 2018-09-24
|
||||
|
||||
* Device: When registering, print the uuid [Pablo Carranza Velez]
|
||||
|
||||
## v7.10.3 - 2018-09-19
|
||||
|
||||
* Include --emulated in the example resin build parameters [Tim Perry]
|
||||
|
||||
## v7.10.2 - 2018-09-18
|
||||
|
||||
* Dependencies: Update resin-semver version to support Balena OS [Lucian Buzzo]
|
||||
|
||||
## v7.10.1 - 2018-09-11
|
||||
|
||||
* Stop Travis deploying to npm (now handled by concourse) [Tim Perry]
|
||||
|
||||
## v7.10.0 - 2018-09-11
|
||||
|
||||
* Update resin-cli-form to 2.x [Pagan Gazzard]
|
||||
|
||||
## v7.9.4 - 2018-09-10
|
||||
|
||||
* Device api keys are no longer used in the registration process [Theodor Gherzan]
|
||||
|
||||
## v7.9.3 - 2018-08-20
|
||||
|
||||
* Fix configuration hangs with some images by expanding the threadpool #952 [Tim Perry]
|
||||
|
||||
## v7.9.2 - 2018-08-15
|
||||
|
||||
* Add warning about re-enabling automatic updates #946 [Pagan Gazzard]
|
||||
|
||||
## v7.9.1 - 2018-08-15
|
||||
|
||||
* Fix errors in `getRequestStream` not being propogated #942 [Pagan Gazzard]
|
||||
|
||||
## v7.9.0 - 2018-08-09
|
||||
|
||||
* Support emulated and nocache options for remote builds #903 [Cameron Diver]
|
||||
|
||||
## v7.8.6 - 2018-08-09
|
||||
|
||||
* Fix bug where the sudo helper failed in os initialize #939 [Tim Perry]
|
||||
|
||||
## v7.8.5 - 2018-08-09
|
||||
|
||||
* Update .resin-sync.yml docs for local push and include example env vars #934 [Tim Perry]
|
||||
|
||||
## v7.8.4 - 2018-08-02
|
||||
|
||||
* Update klaw #936 [Tim Perry]
|
||||
|
||||
## v7.8.3 - 2018-07-25
|
||||
|
||||
* Follow links found during builds #931 [Tim Perry]
|
||||
|
||||
## v7.8.2 - 2018-07-25
|
||||
|
||||
* Update reconfix to fix volume signature errors in local configure #929 [Tim Perry]
|
||||
|
||||
## v7.8.1 - 2018-07-20
|
||||
|
||||
* Be explicit about how much initial history log tailing includes #930 [Tim Perry]
|
||||
|
||||
## v7.8.0 - 2018-07-20
|
||||
|
||||
* Add join/leave commands to promote and move devices between platforms #895 [Akis Kesoglou]
|
||||
|
||||
## v7.7.4 - 2018-07-17
|
||||
|
||||
* Update TypeScript to 2.8.1 #923 [Tim Perry]
|
||||
* Add --version options to os configure & config generate #923 [Tim Perry]
|
||||
* Update OS & config actions to the latest SDK #923 [Tim Perry]
|
||||
|
||||
## v7.7.3 - 2018-07-13
|
||||
|
||||
* Update the deploy key since npm invalidated the old one #927 [Tim Perry]
|
||||
|
||||
## v7.7.2 - 2018-07-13
|
||||
|
||||
* Pin ext2fs to 1.0.7 to avoid temporary deployment issues #926 [Tim Perry]
|
||||
|
||||
## v7.7.1 - 2018-07-12
|
||||
|
||||
* Update CLI to SDK v10 (include new API logs) #925 [Tim Perry]
|
||||
|
||||
## v7.7.0 - 2018-07-11
|
||||
|
||||
* Add --generate-device-api-key parameter to config generate #921 [Tim Perry]
|
||||
|
||||
## v7.6.2 - 2018-06-28
|
||||
|
||||
* Make local commands more resilient to unnamed containers #910 [Tim Perry]
|
||||
|
||||
## v7.6.1 - 2018-06-26
|
||||
|
||||
* Make sure 'resin push' is included in the docs #907 [Tim Perry]
|
||||
|
||||
## v7.6.0 - 2018-06-20
|
||||
|
||||
* Support pinned release preloading #896 [Cameron Diver]
|
||||
|
||||
## v7.5.2 - 2018-06-12
|
||||
|
||||
* Document Pyhton native build dependency #893 [Tim Perry]
|
||||
|
||||
## v7.5.1 - 2018-06-01
|
||||
|
||||
* Add a multicontainer caveat to the env var commands #887 [Tim Perry]
|
||||
|
||||
## v7.5.0 - 2018-05-31
|
||||
|
||||
* Update resin-compose-parse dependency version to 1.10.2 #883 [Ariel Flesler]
|
||||
|
||||
## v7.4.1 - 2018-05-24
|
||||
|
||||
* Update SDK in resin device(s) to ensure the dashboard URL is correct #879 [Tim Perry]
|
||||
|
||||
## v7.4.0 - 2018-05-10
|
||||
|
||||
* Add push command which starts a build on remote resin servers #868 [Cameron Diver]
|
||||
|
||||
## v7.3.8 - 2018-05-03
|
||||
|
||||
* Catch require errors and provide helpful instructions #874 [Tim Perry]
|
||||
* Inline the entire resin-cli-errors module #874 [Tim Perry]
|
||||
|
||||
## v7.3.7 - 2018-04-30
|
||||
|
||||
* Pin node types to v9.0.0 to avoid build errors with transient dependencies #871 [Cameron Diver]
|
||||
|
||||
## v7.3.6 - 2018-04-30
|
||||
|
||||
* Update resin-image-fs to stop non-config commands failing in node 10 #870 [Tim Perry]
|
||||
|
||||
## v7.3.5 - 2018-04-18
|
||||
|
||||
* Don't show Docker container status from devices, as it can be wrong #858 [Tim Perry]
|
||||
|
||||
## v7.3.4 - 2018-04-18
|
||||
|
||||
* Include resin compose schemas in the standalone build #865 [Tim Perry]
|
||||
|
||||
## v7.3.3 - 2018-04-17
|
||||
|
||||
* Don't report lots of user input errors #861 [Tim Perry]
|
||||
* Include Sentry breadcrumbs for context in error reports #861 [Tim Perry]
|
||||
* Update to Sentry 2.x #861 [Tim Perry]
|
||||
|
||||
## v7.3.2 - 2018-04-16
|
||||
|
||||
* Update Dockerode to fix local push issue in standalone builds #862 [Tim Perry]
|
||||
|
||||
## v7.3.1 - 2018-04-13
|
||||
|
||||
* Update resin-compose-parse to v1.8.1 to fix a problem parsing ports #849 [Pablo Carranza Velez]
|
||||
|
||||
## v7.3.0 - 2018-04-12
|
||||
|
||||
* Add 'api-key generate' command #854 [Tim Perry]
|
||||
|
||||
## v7.2.4 - 2018-04-10
|
||||
|
||||
* Explicitly depend on tar-stream #852 [Tim Perry]
|
||||
* Correct documented node version requirement to 6+ #852 [Tim Perry]
|
||||
|
||||
## v7.2.3 - 2018-04-06
|
||||
|
||||
* Add a fast build script to package.json #847 [Cameron Diver]
|
||||
|
||||
## v7.2.2 - 2018-04-04
|
||||
|
||||
* Throw a clear error when logging in with an invalid token #846 [Tim Perry]
|
||||
|
||||
## v7.2.1 - 2018-03-29
|
||||
|
||||
* Update docker-qemu-transpose to avoid the broken 0.4.1 release #839 [Tim Perry]
|
||||
|
||||
## v7.2.0 - 2018-03-29
|
||||
|
||||
* Do not require a login for builds #835 [Tim Perry]
|
||||
* Allow (experimental!) login with API keys #835 [Tim Perry]
|
||||
|
||||
## v7.1.6 - 2018-03-29
|
||||
|
||||
* Fix build emulation for multi-stage builds #838 [Tim Perry]
|
||||
|
||||
## v7.1.5 - 2018-03-27
|
||||
|
||||
* Fix crash when an app is not specified for build command #834 [Akis Kesoglou]
|
||||
|
||||
## v7.1.4 - 2018-03-26
|
||||
|
||||
* Upgrade resin-sync to pull in the fix for #824 #832 [Tim Perry]
|
||||
|
||||
## v7.1.3 - 2018-03-23
|
||||
|
||||
* Prefix all pine options with '$' in preload to avoid pine warnings. #831 [Alexis Svinartchouk]
|
||||
|
||||
## v7.1.2 - 2018-03-23
|
||||
|
||||
* Update resin-preload to 6.2.0 and resin-sdk to 9.0.0-beta16 #830 [Alexis Svinartchouk]
|
||||
|
||||
## v7.1.1 - 2018-03-22
|
||||
|
||||
* Remove explicit anchor links in CLI docs #827 [Zach Walchuk]
|
||||
|
||||
## v7.1.0 - 2018-03-22
|
||||
|
||||
* Warn early if deploying a multicontainer project to an incompatible app #818 [Akis Kesoglou]
|
||||
* Add legacy deploy method back #818 [Akis Kesoglou]
|
||||
|
||||
## v7.0.7 - 2018-03-20
|
||||
|
||||
* Update resin-preload to 6.1.2 #821 [Alexis Svinartchouk]
|
||||
|
||||
## v7.0.6 - 2018-03-20
|
||||
|
||||
* Make sure image name is all lowercase #815 [Akis Kesoglou]
|
||||
* Improve handling of build log output #815 [Akis Kesoglou]
|
||||
|
||||
## v7.0.5 - 2018-03-15
|
||||
|
||||
* Add bash completions #801 [Ronald McCollam]
|
||||
|
||||
## v7.0.4 - 2018-03-15
|
||||
|
||||
* Generate consistent working anchors for both our md output & resin docs #813 [Tim Perry]
|
||||
|
||||
## v7.0.3 - 2018-03-15
|
||||
|
||||
* Fix getting window size when there’s no TTY attached #812 [Akis Kesoglou]
|
||||
|
||||
## v7.0.2 - 2018-03-13
|
||||
|
||||
* Update full CLI docs with recent installation improvements too #807 [Tim Perry]
|
||||
|
||||
## v7.0.1 - 2018-03-12
|
||||
|
||||
* Recommend using unsafe-prem to avoid permission issues on install #805 [Tim Perry]
|
||||
* Remove unnecessary resin-cli-auth dependency #805 [Tim Perry]
|
||||
|
||||
## v7.0.0 - 2018-03-09
|
||||
|
||||
* Add docker-compose-aware builds and deployments #792 [Akis Kesoglou]
|
||||
* *BREAKING*: Remove support for plugins entirely #792 [Tim Perry]
|
||||
* Update dashboard login to use the multicontainer SDK #792 [Alexis Svinartchouk]
|
||||
* Multicontainer preload: Update resin-preload to 6.0.0-beta4 #792 [Alexis Svinartchouk]
|
||||
* Update the keys action to use the multicontainer SDK #792 [Alexis Svinartchouk]
|
||||
* Require multicontainer SDK #792 [Alexis Svinartchouk]
|
||||
|
||||
## v6.13.5 - 2018-03-07
|
||||
|
||||
* Fix prettier configuration to avoid linting errors #802 [Tim Perry]
|
||||
|
||||
## v6.13.4 - 2018-02-22
|
||||
|
||||
* Fix issue where emulated builds broke Docker `ENV` commands #796 [Gergely Imreh]
|
||||
|
||||
## v6.13.3 - 2018-02-20
|
||||
|
||||
* Tweak TS & add missing deps that may cause build failures in some envs #793 [Tim Perry]
|
||||
|
||||
## v6.13.2 - 2018-02-20
|
||||
|
||||
* Ensure login does not wait for the browser process to close #794 [Tim Perry]
|
||||
|
||||
## v6.13.1 - 2018-02-07
|
||||
|
||||
* Move to the correct coffeescript (no hyphen) dependency #786 [Tim Perry]
|
||||
* Add typings for 'ent' #786 [Tim Perry]
|
||||
|
||||
## v6.13.0 - 2018-02-06
|
||||
|
||||
* Add support for Balena in local ssh #777 [Tim Perry]
|
||||
|
||||
## v6.12.9 - 2018-02-05
|
||||
|
||||
* Switch back to upstream global-tunnel-ng #781 [Alexis Svinartchouk]
|
||||
|
||||
## v6.12.8 - 2018-02-03
|
||||
|
||||
* Fix uuid params being parsed a numbers #774 [Thodoris Greasidis]
|
||||
|
||||
## v6.12.7 - 2018-01-30
|
||||
|
||||
* Add 'or mounted resinOS image' #767 [MoranF]
|
||||
|
||||
## v6.12.6 - 2018-01-29
|
||||
|
||||
* Don't use the deprecated 'os configure' format in internal calls #759 [Tim Perry]
|
||||
|
||||
## v6.12.5 - 2018-01-11
|
||||
|
||||
* Fix breakage in deploy command from recent TS conversion #754 [Tim Perry]
|
||||
|
||||
## v6.12.4 - 2018-01-10
|
||||
|
||||
* Start using Prettier #753 [Tim Perry]
|
||||
|
||||
## v6.12.3 - 2018-01-09
|
||||
|
||||
* Lint TypeScript and CoffeeScript with resin-lint #743 [Tim Perry]
|
||||
* Move documentation generation to TypeScript #743 [Tim Perry]
|
||||
* Convert most of utils to TypeScript #743 [Tim Perry]
|
||||
|
||||
## v6.12.2 - 2018-01-09
|
||||
|
||||
* Convert windows paths to posix when passing to tar #748 [Andrew Shirley]
|
||||
|
||||
## v6.12.1 - 2018-01-02
|
||||
|
||||
* Fix deprecation warning for os configure, when passing a bare UUID #744 [Tim Perry]
|
||||
|
||||
## v6.12.0 - 2017-12-19
|
||||
|
||||
* Add ssh option for direct host OS access #737 [Andreas Fitzek]
|
||||
|
||||
## v6.11.0 - 2017-12-18
|
||||
|
||||
* Fix docs generation when building on windows #729 [Tim Perry]
|
||||
* Autodeploy built standalone binaries for all platforms to github #729 [Tim Perry]
|
||||
* Package the CLI into a standalone runnable binary #729 [Tim Perry]
|
||||
* Move from open to opn #729 [Tim Perry]
|
||||
|
||||
## v6.10.3 - 2017-12-15
|
||||
|
||||
* Ensure logout works even with invalid credentials, or if not logged in #730 [Tim Perry]
|
||||
|
||||
## v6.10.2 - 2017-11-27
|
||||
|
||||
* Inline the entire resin-cli-auth module #721 [Tim Perry]
|
||||
|
113
INSTALL.md
Normal file
113
INSTALL.md
Normal file
@ -0,0 +1,113 @@
|
||||
# balena CLI Installation Instructions
|
||||
|
||||
The easiest and recommended way of installing the CLI on all platforms (Windows, Linux, macOS) is
|
||||
to use the [Standalone Installation](#standalone-installation) described below. Some specific CLI
|
||||
commands have a few extra installation steps: see section [Additional Dependencies](#additional-dependencies).
|
||||
|
||||
> **Windows users:** We now have a [YouTube video tutorial](https://www.youtube.com/watch?v=2LApclXFqsg)
|
||||
for installing and getting started with the balena CLI on Windows!
|
||||
|
||||
## Standalone Installation
|
||||
|
||||
1. Download the latest zip file for your OS from https://github.com/balena-io/balena-cli/releases.
|
||||
(Note that "[Darwin](https://en.wikipedia.org/wiki/Darwin_(operating_system))" is the
|
||||
appropriate zip file for macOS.)
|
||||
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:
|
||||
[Windows](https://www.computerhope.com/issues/ch000549.htm) |
|
||||
[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)
|
||||
|
||||
Check that the installation was successful by opening or re-opening a command terminal window
|
||||
(so that the PATH environment variable changes take effect), and running these commands:
|
||||
|
||||
* `balena` - should print the balena CLI help
|
||||
* `balena version` - should print the installed CLI version
|
||||
|
||||
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 version 6 or above (**soon version 8 or above**)
|
||||
* Python 2.7
|
||||
* g++ compiler
|
||||
* make
|
||||
* git
|
||||
* Under Windows, the `windows-build-tools` npm package should be installed too, running the
|
||||
following command in an administrator console (available as 'Command Prompt (Admin)' when
|
||||
pressing Windows+X in Windows 7+) :
|
||||
`npm install -g --production windows-build-tools`
|
||||
|
||||
With those in place, the CLI installation command is:
|
||||
|
||||
```sh
|
||||
$ npm install balena-cli -g --production --unsafe-perm
|
||||
```
|
||||
|
||||
`--unsafe-perm` is only required on systems where the global install directory is not user-writable.
|
||||
This allows npm install steps to download and save prebuilt native binaries. You may be able to omit it,
|
||||
especially if you're using a user-managed node install 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.
|
||||
|
||||
* If you need SSH to work behind a proxy, you will also need to install
|
||||
[`proxytunnel`](http://proxytunnel.sourceforge.net/) (available as a `proxytunnel` package
|
||||
for Ubuntu, for example).
|
||||
Check the [README](https://github.com/balena-io/balena-cli/blob/master/README.md) file
|
||||
for proxy configuration instructions.
|
||||
|
||||
* The `balena sync` command (deprecated) currently requires `rsync` (>= 2.6.9) to be installed:
|
||||
* Linux: `apt-get install rsync`
|
||||
* macOS: [Xcode command-line tools](https://developer.apple.com/xcode/features/) or [homebrew](https://brew.sh/)
|
||||
* Windows: One option is to use the [MinGW](http://www.mingw.org) shell and install the
|
||||
`msys-rsync` package. Check the README file for other shell options under Windows.
|
||||
|
||||
## 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.
|
139
README.md
139
README.md
@ -1,98 +1,109 @@
|
||||
Resin CLI
|
||||
=========
|
||||
# balena CLI
|
||||
|
||||
> The official resin.io CLI tool.
|
||||
The official balena CLI tool.
|
||||
|
||||
[](http://badge.fury.io/js/resin-cli)
|
||||
[](https://david-dm.org/resin-io/resin-cli)
|
||||
[](https://gitter.im/resin-io/chat)
|
||||
[](http://badge.fury.io/js/balena-cli)
|
||||
[](https://david-dm.org/balena-io/balena-cli)
|
||||
[](https://gitter.im/balena-io/chat)
|
||||
|
||||
Requisites
|
||||
----------
|
||||
## About
|
||||
|
||||
- [NodeJS](https://nodejs.org) (>= v4)
|
||||
- [Git](https://git-scm.com)
|
||||
- The following executables should be correctly installed in your shell environment:
|
||||
- `ssh`: Any recent version of the OpenSSH ssh client (required by `resin sync` and `resin ssh`)
|
||||
- if you need `ssh` to work behind the proxy you also need [`proxytunnel`](http://proxytunnel.sourceforge.net/) installed (available as `proxytunnel` package for Ubuntu, for example)
|
||||
- `rsync`: >= 2.6.9 (required by `resin sync`)
|
||||
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!
|
||||
|
||||
##### Windows Support
|
||||
## Installation
|
||||
|
||||
Before installing resin-cli, you'll need a working node-gyp environment. If you don't already have one you'll see native module build errors during installation. To fix this, run `npm install -g --production windows-build-tools` in an administrator console (available as 'Command Prompt (Admin)' when pressing windows+x in Windows 7+).
|
||||
Check the [balena CLI installation instructions on GitHub](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
||||
|
||||
`resin sync` and `resin ssh` have not been thoroughly tested on the standard Windows cmd.exe shell. We recommend using bash (or a similar) shell, like Bash for Windows 10 or [Git for Windows](https://git-for-windows.github.io/).
|
||||
## Getting Started
|
||||
|
||||
If you still want to use `cmd.exe` you will have to use a package manager like MinGW or chocolatey. For MinGW the steps are:
|
||||
### Choosing a shell (command prompt/terminal)
|
||||
|
||||
1. Install [MinGW](http://www.mingw.org).
|
||||
2. Install the `msys-rsync` and `msys-openssh` packages.
|
||||
3. Add MinGW to the `%PATH%` if this hasn't been done by the installer already. The location where the binaries are places is usually `C:\MinGW\msys\1.0\bin`, but it can vary if you selected a different location in the installer.
|
||||
4. Copy your SSH keys to `%homedrive%%homepath\.ssh`.
|
||||
5. If you need `ssh` to work behind the proxy you also need to install [proxytunnel](http://proxytunnel.sourceforge.net/)
|
||||
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:
|
||||
|
||||
Getting Started
|
||||
---------------
|
||||
* Microsoft's [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about)
|
||||
(a.k.a. Microsoft's "bash for Windows 10").
|
||||
* [Git for Windows](https://git-for-windows.github.io/).
|
||||
* [MinGW](http://www.mingw.org): install the `msys-rsync` and `msys-openssh` packages too.
|
||||
|
||||
### Install
|
||||
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.
|
||||
|
||||
This might require elevated privileges in some environments.
|
||||
### 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:
|
||||
|
||||
```sh
|
||||
$ npm install --global --production resin-cli
|
||||
$ balena login
|
||||
```
|
||||
|
||||
### Login
|
||||
### Proxy support
|
||||
|
||||
```sh
|
||||
$ resin login
|
||||
```
|
||||
HTTP(S) proxies can be configured through any of the following methods, in order of preference:
|
||||
|
||||
_(Typically useful, but not strictly required for all commands)_
|
||||
* Set the \`BALENARC_PROXY\` environment variable in URL format (with protocol, host, port, and
|
||||
optionally basic auth).
|
||||
* Alternatively, use the [balena config file](https://www.npmjs.com/package/balena-settings-client#documentation)
|
||||
(project-specific or user-level) and set the \`proxy\` setting. It can be:
|
||||
* A string in URL format, or
|
||||
* An object in the [global-tunnel-ng options format](https://www.npmjs.com/package/global-tunnel-ng#options) (which allows more control).
|
||||
* Alternatively, set the conventional \`https_proxy\` / \`HTTPS_PROXY\` / \`http_proxy\` / \`HTTP_PROXY\`
|
||||
environment variable (in the same standard URL format).
|
||||
|
||||
### Run commands
|
||||
To get a proxy to work with the `balena ssh` command, check the
|
||||
[installation instructions](https://github.com/balena-io/balena-cli/blob/master/INSTALL.md).
|
||||
|
||||
Take a look at the full command documentation at [https://docs.resin.io/tools/cli/](https://docs.resin.io/tools/cli/#table-of-contents
|
||||
), or by running `resin help`.
|
||||
## 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`.
|
||||
|
||||
Plugins
|
||||
-------
|
||||
## Support, FAQ and troubleshooting
|
||||
|
||||
The Resin CLI can be extended with plugins to automate laborious tasks and overall provide a better experience when working with Resin.io. Check the [plugin development tutorial](https://github.com/resin-io/resin-plugin-hello) to learn how to build your own!
|
||||
If you come across any problems or would like to get in touch:
|
||||
|
||||
FAQ
|
||||
---
|
||||
* 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/).
|
||||
|
||||
### Where is my configuration file?
|
||||
## Contributing
|
||||
|
||||
The per-user configuration file lives in `$HOME/.resinrc.yml` or `%UserProfile%\_resinrc.yml`, in Unix based operating systems and Windows respectively.
|
||||
The balena CLI is an open source project and your contribution is welcome!
|
||||
|
||||
The Resin CLI also attempts to read a `resinrc.yml` file in the current directory, which takes precedence over the per-user configuration file.
|
||||
The CLI was originally written in [CoffeeScript](https://coffeescript.org), but we have decided to
|
||||
migrate to [TypeScript](https://www.typescriptlang.org/) in order to take advantage of static
|
||||
typing and formal programming interfaces. The migration is taking place gradually, as part of
|
||||
maintenance work or the implementation of new features.
|
||||
|
||||
### How do I point the Resin CLI to staging?
|
||||
After cloning this repository and running `npm install` you can build the CLI using `npm run build`.
|
||||
You can then run the generated build using `./bin/balena`.
|
||||
In order to ease development:
|
||||
|
||||
The easiest way is to set the `RESINRC_RESIN_URL=resinstaging.io` environment variable.
|
||||
* you can build the CLI using the `npm run build:fast` variant which skips some of the build steps or
|
||||
* you can use `./bin/balena-dev` which live transpiles the sources of the CLI.
|
||||
|
||||
Alternatively, you can edit your configuration file and set `resinUrl: resinstaging.io` to persist this setting.
|
||||
In either case, before opening a PR be sure to also test your changes with `npm test`.
|
||||
|
||||
### How do I make the Resin CLI persist data in another directory?
|
||||
## Note on editing this README document
|
||||
|
||||
The Resin CLI persists your session token, as well as cached images in `$HOME/.resin` or `%UserProfile%\_resin`.
|
||||
This file is edited/created by hand, but it is then automatically parsed to extract selected
|
||||
subsections for the [CLI's web documentation page](https://www.balena.io/docs/reference/cli/).
|
||||
The code that parses this file is in [`automation/capitanodoc/capitanodoc.ts`
|
||||
](https://github.com/balena-io/balena-cli/blob/master/automation/capitanodoc/capitanodoc.ts).
|
||||
|
||||
Pointing the Resin 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 resinOS, which erases all data after a restart.
|
||||
## License
|
||||
|
||||
You can accomplish this by setting `RESINRC_DATA_DIRECTORY=/opt/resin` or adding `dataDirectory: /opt/resin` to your configuration file, replacing `/opt/resin` with your desired directory.
|
||||
|
||||
Support
|
||||
-------
|
||||
|
||||
If you're having any problems, check our [troubleshooting guide](https://github.com/resin-io/resin-cli/blob/master/TROUBLESHOOTING.md) and if your problem is not addressed there, please [raise an issue](https://github.com/resin-io/resin-cli/issues/new) on GitHub and the resin.io team will be happy to help.
|
||||
|
||||
You can also get in touch with us in the resin.io [forums](https://forums.resin.io/).
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The project is licensed under the Apache 2.0 license.
|
||||
The project is licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0).
|
||||
A copy is also available in the LICENSE file in this repository.
|
||||
|
@ -1,15 +1,41 @@
|
||||
Troubleshooting
|
||||
===============
|
||||
# FAQ & Troubleshooting
|
||||
|
||||
This document contains common issues related to the Resin CLI, and how to fix them.
|
||||
This document contains some common issues, questions and answers related to the balena CLI.
|
||||
|
||||
### After burning to an sdcard, my device doesn't boot
|
||||
## Where is my configuration file?
|
||||
|
||||
The per-user configuration file lives in `$HOME/.balenarc.yml` or `%UserProfile%\_balenarc.yml`, in
|
||||
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?
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## After burning to an sdcard, my device doesn't boot
|
||||
|
||||
- The downloaded image is not complete (download was interrupted).
|
||||
|
||||
Please clean the cache (`%HOME/.resin/cache` or `C:\Users\<user>\_resin\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.
|
||||
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.
|
||||
|
||||
### I get a permission error when burning to an sdcard
|
||||
## I get a permission error when burning to an sdcard
|
||||
|
||||
- The SDCard is locked.
|
||||
|
||||
@ -24,36 +50,36 @@ net.js:156
|
||||
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\resin-cli\node_modules\inquirer\node_modules\readline2\index.js:31:43)
|
||||
at PromptUI.UI (C:\cygwin\home\Juan Cruz Viotti\Projects\resin-cli\node_modules\inquirer\lib\ui\baseUI.js:23:40)
|
||||
at new PromptUI (C:\cygwin\home\Juan Cruz Viotti\Projects\resin-cli\node_modules\inquirer\lib\ui\prompt.js:26:8)
|
||||
at Object.promptModule [as prompt] (C:\cygwin\home\Juan Cruz Viotti\Projects\resin-cli\node_modules\inquirer\lib\inquirer.js:27:14)
|
||||
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).
|
||||
|
||||
### I get `Invalid MBR boot signature` when configuring a device
|
||||
## I get `Invalid MBR boot signature` when configuring a device
|
||||
|
||||
This error, accompanied with something like: `Expected 0xAA55, but saw 0x29FE` usually indicates a corrupted device operating system image in the cache, due to bad a internet connection during the download process.
|
||||
|
||||
Try clearing the cache with the following command and trying again:
|
||||
|
||||
```sh
|
||||
$ rm -rf $HOME/.resin/cache
|
||||
$ rm -rf $HOME/.balena/cache
|
||||
```
|
||||
|
||||
Or in Windows:
|
||||
|
||||
```sh
|
||||
> del /s /q %UserProfile%\_resin\cache
|
||||
> del /s /q %UserProfile%\_balena\cache
|
||||
```
|
||||
|
||||
### I get `EACCES: permission denied` when logging in
|
||||
## I get `EACCES: permission denied` when logging in
|
||||
|
||||
The Resin CLI stores the session token in `$HOME/.resin` or `C:\Users\<user>\_resin` 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 Resin 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 you ran the balena CLI as `root`, and thus the directory got owned by him.
|
||||
|
||||
Try resetting the ownership by running:
|
||||
|
||||
```sh
|
||||
$ sudo chown -R <user> $HOME/.resin
|
||||
$ sudo chown -R <user> $HOME/.balena
|
||||
```
|
||||
|
34
appveyor.yml
Normal file
34
appveyor.yml
Normal file
@ -0,0 +1,34 @@
|
||||
# appveyor file
|
||||
# http://www.appveyor.com/docs/appveyor-yml
|
||||
|
||||
init:
|
||||
- git config --global core.autocrlf input
|
||||
|
||||
cache:
|
||||
- C:\Users\appveyor\.node-gyp
|
||||
- '%AppData%\npm-cache'
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
|
||||
# what combinations to test
|
||||
environment:
|
||||
matrix:
|
||||
- nodejs_version: 6
|
||||
|
||||
install:
|
||||
- ps: Install-Product node $env:nodejs_version x64
|
||||
- npm install -g npm@4
|
||||
- set PATH=%APPDATA%\npm;%PATH%
|
||||
- npm install
|
||||
|
||||
build: off
|
||||
|
||||
test_script:
|
||||
- node --version
|
||||
- npm --version
|
||||
- cmd: npm test
|
||||
|
||||
deploy_script:
|
||||
- IF "%APPVEYOR_REPO_TAG%" == "true" (npm run release)
|
||||
- IF NOT "%APPVEYOR_REPO_TAG%" == "true" (echo 'Not tagged, skipping deploy')
|
63
automation/build-bin.ts
Executable file
63
automation/build-bin.ts
Executable file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @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 Bluebird from 'bluebird';
|
||||
import * as filehound from 'filehound';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { exec as execPkg } from 'pkg';
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
console.log('Building package...\n');
|
||||
|
||||
execPkg(['--target', 'host', '--output', 'build-bin/balena', 'package.json'])
|
||||
.then(() => {
|
||||
const xpaths: Array<[string, string[]]> = [
|
||||
// [platform, [path, to, file]]
|
||||
['*', ['opn', 'xdg-open']],
|
||||
['darwin', ['denymount', 'bin', 'denymount']],
|
||||
];
|
||||
return Bluebird.map(xpaths, ([platform, xpath]) => {
|
||||
if (platform === '*' || platform === process.platform) {
|
||||
// eg copy from node_modules/opn/xdg-open to build-bin/xdg-open
|
||||
return fs.copy(
|
||||
path.join(ROOT, 'node_modules', ...xpath),
|
||||
path.join(ROOT, 'build-bin', xpath.pop()!),
|
||||
);
|
||||
}
|
||||
}).return();
|
||||
})
|
||||
.then(() => {
|
||||
return filehound
|
||||
.create()
|
||||
.paths(path.join(ROOT, 'node_modules'))
|
||||
.ext(['node', 'dll'])
|
||||
.find();
|
||||
})
|
||||
.then(nativeExtensions => {
|
||||
console.log(`\nCopying to build-bin:\n${nativeExtensions.join('\n')}`);
|
||||
|
||||
return nativeExtensions.map(extPath => {
|
||||
return fs.copy(
|
||||
extPath,
|
||||
extPath.replace(
|
||||
path.join(ROOT, 'node_modules'),
|
||||
path.join(ROOT, 'build-bin'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
151
automation/capitanodoc/capitanodoc.ts
Normal file
151
automation/capitanodoc/capitanodoc.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @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 path from 'path';
|
||||
|
||||
import { MarkdownFileParser } from './utils';
|
||||
|
||||
/**
|
||||
* This is the skeleton of CLI documentation/reference web page at:
|
||||
* https://www.balena.io/docs/reference/cli/
|
||||
*
|
||||
* The `getCapitanoDoc` function in this module parses README.md and adds
|
||||
* some content to this object.
|
||||
*/
|
||||
const capitanoDoc = {
|
||||
title: 'Balena CLI Documentation',
|
||||
introduction: '',
|
||||
categories: [
|
||||
{
|
||||
title: 'API keys',
|
||||
files: ['build/actions/api-key.js'],
|
||||
},
|
||||
{
|
||||
title: 'Application',
|
||||
files: ['build/actions/app.js'],
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
files: ['build/actions/auth.js'],
|
||||
},
|
||||
{
|
||||
title: 'Device',
|
||||
files: ['build/actions/device.js'],
|
||||
},
|
||||
{
|
||||
title: 'Environment Variables',
|
||||
files: ['build/actions/environment-variables.js'],
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
files: ['build/actions/tags.js'],
|
||||
},
|
||||
{
|
||||
title: 'Help',
|
||||
files: ['build/actions/help.js'],
|
||||
},
|
||||
{
|
||||
title: 'Information',
|
||||
files: ['build/actions/info.js'],
|
||||
},
|
||||
{
|
||||
title: 'Keys',
|
||||
files: ['build/actions/keys.js'],
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
files: ['build/actions/logs.js'],
|
||||
},
|
||||
{
|
||||
title: 'Sync',
|
||||
files: ['build/actions/sync.js'],
|
||||
},
|
||||
{
|
||||
title: 'SSH',
|
||||
files: ['build/actions/ssh.js', 'build/actions/tunnel.js'],
|
||||
},
|
||||
{
|
||||
title: 'Notes',
|
||||
files: ['build/actions/notes.js'],
|
||||
},
|
||||
{
|
||||
title: 'OS',
|
||||
files: ['build/actions/os.js'],
|
||||
},
|
||||
{
|
||||
title: 'Config',
|
||||
files: ['build/actions/config.js'],
|
||||
},
|
||||
{
|
||||
title: 'Preload',
|
||||
files: ['build/actions/preload.js'],
|
||||
},
|
||||
{
|
||||
title: 'Push',
|
||||
files: ['build/actions/push.js'],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
files: ['build/actions/settings.js'],
|
||||
},
|
||||
{
|
||||
title: 'Local',
|
||||
files: ['build/actions/local/index.js'],
|
||||
},
|
||||
{
|
||||
title: 'Deploy',
|
||||
files: ['build/actions/build.js', 'build/actions/deploy.js'],
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
files: ['build/actions/join.js', 'build/actions/leave.js'],
|
||||
},
|
||||
{
|
||||
title: 'Utilities',
|
||||
files: ['build/actions/util.js'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Modify and return the `capitanoDoc` object above in order to render the
|
||||
* CLI documentation/reference web page at:
|
||||
* https://www.balena.io/docs/reference/cli/
|
||||
*
|
||||
* This function parses the README.md file to extract relevant sections
|
||||
* for the documentation web page.
|
||||
*/
|
||||
export async function getCapitanoDoc(): Promise<typeof capitanoDoc> {
|
||||
const readmePath = path.join(__dirname, '..', '..', 'README.md');
|
||||
const mdParser = new MarkdownFileParser(readmePath);
|
||||
const sections: string[] = await Promise.all([
|
||||
mdParser.getSectionOfTitle('About').then((sectionLines: string) => {
|
||||
// delete the title of the 'About' section for the web page
|
||||
const match = /^(#+)\s+.+?\n\s*([^]*)/.exec(sectionLines);
|
||||
if (!match || match.length < 3) {
|
||||
throw new Error(`Error parsing section title`);
|
||||
}
|
||||
// match[1] has the title, match[2] has the rest
|
||||
return match && match[2];
|
||||
}),
|
||||
mdParser.getSectionOfTitle('Installation'),
|
||||
mdParser.getSectionOfTitle('Getting Started'),
|
||||
mdParser.getSectionOfTitle('Support, FAQ and troubleshooting'),
|
||||
]);
|
||||
capitanoDoc.introduction = sections.join('\n');
|
||||
return capitanoDoc;
|
||||
}
|
14
automation/capitanodoc/doc-types.d.ts
vendored
Normal file
14
automation/capitanodoc/doc-types.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import { CommandDefinition } from 'capitano';
|
||||
|
||||
export interface Document {
|
||||
title: string;
|
||||
introduction: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
title: string;
|
||||
commands: CommandDefinition[];
|
||||
}
|
||||
|
||||
export { CommandDefinition as Command };
|
74
automation/capitanodoc/index.ts
Normal file
74
automation/capitanodoc/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @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 { getCapitanoDoc } from './capitanodoc';
|
||||
import { Category, Document } from './doc-types';
|
||||
import * as markdown from './markdown';
|
||||
|
||||
/**
|
||||
* Generates the markdown document (as a string) for the CLI documentation
|
||||
* page on the web: https://www.balena.io/docs/reference/cli/
|
||||
*/
|
||||
export async function renderMarkdown(): Promise<string> {
|
||||
const capitanodoc = await getCapitanoDoc();
|
||||
const result: Document = {
|
||||
title: capitanodoc.title,
|
||||
introduction: capitanodoc.introduction,
|
||||
categories: [],
|
||||
};
|
||||
|
||||
for (const commandCategory of capitanodoc.categories) {
|
||||
const category: Category = {
|
||||
title: commandCategory.title,
|
||||
commands: [],
|
||||
};
|
||||
|
||||
for (const file of commandCategory.files) {
|
||||
const actions: any = require(path.join(process.cwd(), file));
|
||||
|
||||
if (actions.signature) {
|
||||
category.commands.push(_.omit(actions, 'action'));
|
||||
} else {
|
||||
for (const actionName of Object.keys(actions)) {
|
||||
const actionCommand = actions[actionName];
|
||||
category.commands.push(_.omit(actionCommand, 'action'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.categories.push(category);
|
||||
}
|
||||
|
||||
return markdown.render(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the CLI docs markdown to stdout.
|
||||
* See package.json for how the output is redirected to a file.
|
||||
*/
|
||||
async function printMarkdown() {
|
||||
try {
|
||||
console.log(await renderMarkdown());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
printMarkdown();
|
94
automation/capitanodoc/markdown.ts
Normal file
94
automation/capitanodoc/markdown.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @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 ent from 'ent';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { Category, Command, Document } from './doc-types';
|
||||
import * as utils from './utils';
|
||||
|
||||
export function renderCommand(command: Command) {
|
||||
let result = `## ${ent.encode(command.signature)}\n\n${command.help}\n`;
|
||||
|
||||
if (!_.isEmpty(command.options)) {
|
||||
result += '\n### Options';
|
||||
|
||||
for (const option of command.options!) {
|
||||
result += `\n\n#### ${utils.parseSignature(option)}\n\n${
|
||||
option.description
|
||||
}`;
|
||||
}
|
||||
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderCategory(category: Category) {
|
||||
let result = `# ${category.title}\n`;
|
||||
|
||||
for (const command of category.commands) {
|
||||
result += `\n${renderCommand(command)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getAnchor(command: Command) {
|
||||
return (
|
||||
'#' +
|
||||
command.signature
|
||||
.replace(/\s/g, '-')
|
||||
.replace(/</g, '-')
|
||||
.replace(/>/g, '-')
|
||||
.replace(/\[/g, '-')
|
||||
.replace(/\]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/-$/, '')
|
||||
.replace(/\.\.\./g, '')
|
||||
.replace(/\|/g, '')
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function renderToc(categories: Category[]) {
|
||||
let result = `# CLI Command Reference\n`;
|
||||
|
||||
for (const category of categories) {
|
||||
result += `\n- ${category.title}\n\n`;
|
||||
|
||||
for (const command of category.commands) {
|
||||
result += `\t- [${ent.encode(command.signature)}](${getAnchor(
|
||||
command,
|
||||
)})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function render(doc: Document) {
|
||||
let result = `# ${doc.title}\n\n${doc.introduction}\n\n${renderToc(
|
||||
doc.categories,
|
||||
)}`;
|
||||
|
||||
for (const category of doc.categories) {
|
||||
result += `\n${renderCategory(category)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
138
automation/capitanodoc/utils.ts
Normal file
138
automation/capitanodoc/utils.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @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 { OptionDefinition } from 'capitano';
|
||||
import * as ent from 'ent';
|
||||
import * as fs from 'fs';
|
||||
import * as _ from 'lodash';
|
||||
import * as readline from 'readline';
|
||||
|
||||
export function getOptionPrefix(signature: string) {
|
||||
if (signature.length > 1) {
|
||||
return '--';
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
export function getOptionSignature(signature: string) {
|
||||
return `${getOptionPrefix(signature)}${signature}`;
|
||||
}
|
||||
|
||||
export function parseSignature(option: OptionDefinition) {
|
||||
let result = getOptionSignature(option.signature);
|
||||
|
||||
if (_.isArray(option.alias)) {
|
||||
for (const alias of option.alias) {
|
||||
result += `, ${getOptionSignature(alias)}`;
|
||||
}
|
||||
} else if (_.isString(option.alias)) {
|
||||
result += `, ${getOptionSignature(option.alias)}`;
|
||||
}
|
||||
|
||||
if (option.parameter) {
|
||||
result += ` <${option.parameter}>`;
|
||||
}
|
||||
|
||||
return ent.encode(result);
|
||||
}
|
||||
|
||||
export class MarkdownFileParser {
|
||||
constructor(public mdFilePath: string) {}
|
||||
|
||||
/**
|
||||
* Extract the lines of a markdown document section with the given title.
|
||||
* For example, consider this sample markdown document:
|
||||
* ```
|
||||
* # balena CLI
|
||||
*
|
||||
* ## Introduction
|
||||
* Lorem ipsum dolor sit amet, consectetur adipiscing elit,
|
||||
*
|
||||
* ## Getting Started
|
||||
* sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
*
|
||||
* ### Prerequisites
|
||||
* - Foo
|
||||
* - Bar
|
||||
*
|
||||
* ## Support
|
||||
* Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
|
||||
* ```
|
||||
*
|
||||
* Calling getSectionOfTitle('Getting Started') for the markdown doc above
|
||||
* returns everything from line '## Getting Started' (included) to line
|
||||
* '## Support' (excluded). This method counts the number of '#' characters
|
||||
* to determine that subsections should be included as part of the parent
|
||||
* section.
|
||||
*
|
||||
* @param title The section title without '#' chars, eg. 'Getting Started'
|
||||
*/
|
||||
public async getSectionOfTitle(
|
||||
title: string,
|
||||
includeSubsections = true,
|
||||
): Promise<string> {
|
||||
let foundSectionLines: string[];
|
||||
let foundSectionLevel = 0;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(this.mdFilePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on('line', line => {
|
||||
// try to match a line like "## Getting Started", where the number
|
||||
// of '#' characters is the sectionLevel ('##' -> 2), and the
|
||||
// sectionTitle is "Getting Started"
|
||||
const match = /^(#+)\s+(.+)/.exec(line);
|
||||
if (match) {
|
||||
const sectionLevel = match[1].length;
|
||||
const sectionTitle = match[2];
|
||||
|
||||
// If the target section had already been found: append a line, or end it
|
||||
if (foundSectionLines) {
|
||||
if (!includeSubsections || sectionLevel <= foundSectionLevel) {
|
||||
// end previously found section
|
||||
rl.close();
|
||||
}
|
||||
} else if (sectionTitle === title) {
|
||||
// found the target section
|
||||
foundSectionLevel = sectionLevel;
|
||||
foundSectionLines = [];
|
||||
}
|
||||
}
|
||||
if (foundSectionLines) {
|
||||
foundSectionLines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
rl.on('close', () => {
|
||||
if (foundSectionLines) {
|
||||
resolve(foundSectionLines.join('\n'));
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Markdown section not found: title="${title}" file="${
|
||||
this.mdFilePath
|
||||
}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
38
automation/custom-types.d.ts
vendored
Normal file
38
automation/custom-types.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
declare module 'pkg' {
|
||||
export function exec(args: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
declare module 'filehound' {
|
||||
export function create(): FileHound;
|
||||
|
||||
export interface FileHound {
|
||||
paths(paths: string[]): FileHound;
|
||||
paths(...paths: string[]): FileHound;
|
||||
ext(extensions: string[]): FileHound;
|
||||
ext(...extensions: string[]): FileHound;
|
||||
find(): Promise<string[]>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'publish-release' {
|
||||
interface PublishOptions {
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
name: string;
|
||||
reuseRelease?: boolean;
|
||||
assets: string[];
|
||||
}
|
||||
|
||||
interface Release {
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
let publishRelease: (
|
||||
args: PublishOptions,
|
||||
callback: (e: Error, release: Release) => void,
|
||||
) => void;
|
||||
|
||||
export = publishRelease;
|
||||
}
|
84
automation/deploy-bin.ts
Normal file
84
automation/deploy-bin.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @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 archiver from 'archiver';
|
||||
import * as Promise from 'bluebird';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as mkdirp from 'mkdirp';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as publishRelease from 'publish-release';
|
||||
|
||||
import * as packageJSON from '../package.json';
|
||||
|
||||
const publishReleaseAsync = Promise.promisify(publishRelease);
|
||||
const mkdirpAsync = Promise.promisify<string | null, string>(mkdirp);
|
||||
|
||||
const { GITHUB_TOKEN } = process.env;
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
const version = 'v' + packageJSON.version;
|
||||
const outputFile = path.join(
|
||||
ROOT,
|
||||
'build-zip',
|
||||
`balena-cli-${version}-${os.platform()}-${os.arch()}.zip`,
|
||||
);
|
||||
|
||||
mkdirpAsync(path.dirname(outputFile))
|
||||
.then(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
console.log('Zipping build...');
|
||||
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 7 },
|
||||
});
|
||||
archive.directory(path.join(ROOT, 'build-bin'), 'balena-cli');
|
||||
|
||||
const outputStream = fs.createWriteStream(outputFile);
|
||||
|
||||
outputStream.on('close', resolve);
|
||||
outputStream.on('error', reject);
|
||||
|
||||
archive.on('error', reject);
|
||||
archive.on('warning', console.warn);
|
||||
|
||||
archive.pipe(outputStream);
|
||||
archive.finalize();
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
console.log('Build zipped');
|
||||
console.log('Publishing build...');
|
||||
|
||||
return publishReleaseAsync({
|
||||
token: GITHUB_TOKEN || '',
|
||||
owner: 'balena-io',
|
||||
repo: 'balena-cli',
|
||||
tag: version,
|
||||
name: `balena-CLI ${version}`,
|
||||
reuseRelease: true,
|
||||
assets: [outputFile],
|
||||
});
|
||||
})
|
||||
.then(release => {
|
||||
console.log(`Release ${version} successful: ${release.html_url}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Release failed');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
16
automation/tsconfig.json
Normal file
16
automation/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2015",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"preserveConstEnums": true,
|
||||
"removeComments": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"../typings/*.d.ts"
|
||||
]
|
||||
}
|
73
balena-completion.bash
Normal file
73
balena-completion.bash
Normal file
@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
_balena_complete()
|
||||
{
|
||||
local cur prev
|
||||
|
||||
# Valid top-level completions
|
||||
commands="app apps build config deploy device devices env envs help key \
|
||||
keys local login logout logs note os preload quickstart settings \
|
||||
signup ssh sync util version whoami"
|
||||
# Sub-completions
|
||||
app_cmds="create restart rm"
|
||||
config_cmds="generate inject read reconfigure write"
|
||||
device_cmds="identify init move public-url reboot register rename rm \
|
||||
shutdown"
|
||||
device_public_url_cmds="disable enable status"
|
||||
env_cmds="add rename rm"
|
||||
key_cmds="add rm"
|
||||
local_cmds="configure flash logs push scan ssh stop"
|
||||
os_cmds="build-config configure download initialize versions"
|
||||
util_cmds="available-drives"
|
||||
|
||||
|
||||
COMPREPLY=()
|
||||
cur=${COMP_WORDS[COMP_CWORD]}
|
||||
prev=${COMP_WORDS[COMP_CWORD-1]}
|
||||
|
||||
if [ $COMP_CWORD -eq 1 ]
|
||||
then
|
||||
COMPREPLY=( $(compgen -W "${commands}" -- $cur) )
|
||||
elif [ $COMP_CWORD -eq 2 ]
|
||||
then
|
||||
case "$prev" in
|
||||
"app")
|
||||
COMPREPLY=( $(compgen -W "$app_cmds" -- $cur) )
|
||||
;;
|
||||
"config")
|
||||
COMPREPLY=( $(compgen -W "$config_cmds" -- $cur) )
|
||||
;;
|
||||
"device")
|
||||
COMPREPLY=( $(compgen -W "$device_cmds" -- $cur) )
|
||||
;;
|
||||
"env")
|
||||
COMPREPLY=( $(compgen -W "$env_cmds" -- $cur) )
|
||||
;;
|
||||
"key")
|
||||
COMPREPLY=( $(compgen -W "$key_cmds" -- $cur) )
|
||||
;;
|
||||
"local")
|
||||
COMPREPLY=( $(compgen -W "$local_cmds" -- $cur) )
|
||||
;;
|
||||
"os")
|
||||
COMPREPLY=( $(compgen -W "$os_cmds" -- $cur) )
|
||||
;;
|
||||
"util")
|
||||
COMPREPLY=( $(compgen -W "$util_cmds" -- $cur) )
|
||||
;;
|
||||
"*")
|
||||
;;
|
||||
esac
|
||||
elif [ $COMP_CWORD -eq 3 ]
|
||||
then
|
||||
case "$prev" in
|
||||
"public-url")
|
||||
COMPREPLY=( $(compgen -W "$device_public_url_cmds" -- $cur) )
|
||||
;;
|
||||
"*")
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
}
|
||||
complete -F _balena_complete balena
|
11
bin/balena
Executable file
11
bin/balena
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// We boost the threadpool size as ext2fs can deadlock with some
|
||||
// operations otherwise, if the pool runs out.
|
||||
process.env.UV_THREADPOOL_SIZE = '64';
|
||||
|
||||
// Use fast-boot to cache require lookups, speeding up startup
|
||||
require('fast-boot2').start({
|
||||
cacheFile: __dirname + '/.fast-boot.json'
|
||||
})
|
||||
require('../build/app');
|
19
bin/balena-dev
Executable file
19
bin/balena-dev
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// ****************************************************************************
|
||||
// THIS IS FOR DEV PERROSES ONLY AND WILL NOT BE PART OF THE PUBLISHED PACKAGE
|
||||
// Before opening a PR you should build and test your changes using bin/balena
|
||||
// ****************************************************************************
|
||||
|
||||
// We boost the threadpool size as ext2fs can deadlock with some
|
||||
// operations otherwise, if the pool runs out.
|
||||
process.env.UV_THREADPOOL_SIZE = '64';
|
||||
|
||||
// Use fast-boot to cache require lookups, speeding up startup
|
||||
require('fast-boot2').start({
|
||||
cacheFile: '.fast-boot.json'
|
||||
})
|
||||
process.env['TS_NODE_PROJECT'] = require('path').dirname(__dirname);
|
||||
require('coffeescript/register');
|
||||
require('ts-node/register');
|
||||
require('../lib/app');
|
@ -1,115 +0,0 @@
|
||||
# coffeelint: disable=max_line_length
|
||||
|
||||
module.exports =
|
||||
title: 'Resin CLI Documentation'
|
||||
introduction: '''
|
||||
This tool allows you to interact with the resin.io api from the comfort of your command line.
|
||||
|
||||
Please make sure your system meets the requirements as specified in the [README](https://github.com/resin-io/resin-cli).
|
||||
|
||||
To get started download the CLI from npm.
|
||||
|
||||
$ npm install resin-cli -g
|
||||
|
||||
Then authenticate yourself:
|
||||
|
||||
$ resin login
|
||||
|
||||
Now you have access to all the commands referenced below.
|
||||
|
||||
## Proxy support
|
||||
|
||||
The CLI does support HTTP(S) proxies.
|
||||
|
||||
You can configure the proxy using several methods (in order of their precedence):
|
||||
|
||||
* set the `RESINRC_PROXY` environment variable in the URL format (with protocol, host, port, and optionally the basic auth),
|
||||
* use the [resin config file](https://www.npmjs.com/package/resin-settings-client#documentation) (project-specific or user-level)
|
||||
and set the `proxy` setting. This can be:
|
||||
* a string in the URL format,
|
||||
* or an object following [this format](https://www.npmjs.com/package/global-tunnel-ng#options), which allows more control,
|
||||
* or set the conventional `https_proxy` / `HTTPS_PROXY` / `http_proxy` / `HTTP_PROXY`
|
||||
environment variable (in the same standard URL format).
|
||||
'''
|
||||
|
||||
categories: [
|
||||
{
|
||||
title: 'Application'
|
||||
files: [ 'lib/actions/app.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
files: [ 'lib/actions/auth.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Device',
|
||||
files: [ 'lib/actions/device.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Environment Variables',
|
||||
files: [ 'lib/actions/environment-variables.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Help',
|
||||
files: [ 'lib/actions/help.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Information',
|
||||
files: [ 'lib/actions/info.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Keys',
|
||||
files: [ 'lib/actions/keys.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
files: [ 'lib/actions/logs.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Sync',
|
||||
files: [ 'lib/actions/sync.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'SSH',
|
||||
files: [ 'lib/actions/ssh.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Notes',
|
||||
files: [ 'lib/actions/notes.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'OS',
|
||||
files: [ 'lib/actions/os.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Config',
|
||||
files: [ 'lib/actions/config.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Preload',
|
||||
files: [ 'lib/actions/preload.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
files: [ 'lib/actions/settings.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Wizard',
|
||||
files: [ 'lib/actions/wizard.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Local',
|
||||
files: [ 'lib/actions/local/index.coffee' ]
|
||||
},
|
||||
{
|
||||
title: 'Deploy',
|
||||
files: [
|
||||
'lib/actions/build.coffee'
|
||||
'lib/actions/deploy.coffee'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Utilities',
|
||||
files: [ 'lib/actions/util.coffee' ]
|
||||
},
|
||||
]
|
@ -1,4 +1,4 @@
|
||||
# Provisioning Resin.io devices in automated (non-interactive) mode
|
||||
# Provisioning balena devices in automated (non-interactive) mode
|
||||
|
||||
This document describes how to run the `device init` command in non-interactive mode.
|
||||
|
||||
@ -7,7 +7,7 @@ It requires collecting some preliminary information _once_.
|
||||
The final command to provision the device looks like this:
|
||||
|
||||
```bash
|
||||
resin device init --app APP_ID --os-version OS_VERSION --drive DRIVE --config CONFIG_FILE --yes
|
||||
balena device init --app APP_ID --os-version OS_VERSION --drive DRIVE --config CONFIG_FILE --yes
|
||||
|
||||
```
|
||||
|
||||
@ -20,15 +20,15 @@ But before you can run it you need to collect the parameters and build the confi
|
||||
|
||||
1. `DEVICE_TYPE`. Run
|
||||
```bash
|
||||
resin devices supported
|
||||
balena devices supported
|
||||
```
|
||||
and find the _slug_ for your target device type, like _raspberrypi3_.
|
||||
|
||||
1. `APP_ID`. Create an application (`resin app create APP_NAME --type DEVICE_TYPE`) or find an existing one (`resin apps`) and notice its ID.
|
||||
1. `APP_ID`. Create an application (`balena app create APP_NAME --type DEVICE_TYPE`) or find an existing one (`balena apps`) and notice its ID.
|
||||
|
||||
1. `OS_VERSION`. Run
|
||||
```bash
|
||||
resin os versions DEVICE_TYPE
|
||||
balena os versions DEVICE_TYPE
|
||||
```
|
||||
and pick the version that you need, like _v2.0.6+rev1.prod_.
|
||||
_Note_ that even though we support _semver ranges_ it's recommended to use the exact version when doing the automated provisioning as it
|
||||
@ -36,10 +36,10 @@ But before you can run it you need to collect the parameters and build the confi
|
||||
|
||||
1. `DRIVE`. Plug in your target medium (SD card or the USB stick, depending on your device type) and run
|
||||
```bash
|
||||
resin util available-drives
|
||||
balena util available-drives
|
||||
```
|
||||
and get the drive name, like _/dev/sdb_ or _/dev/mmcblk0_.
|
||||
The resin CLI will not display the system drives to protect you,
|
||||
The balena CLI will not display the system drives to protect you,
|
||||
but still please check very carefully that you've picked the correct drive as it will be erased during the provisioning process.
|
||||
|
||||
Now we have all the parameters -- time to build the config file.
|
||||
@ -50,21 +50,21 @@ Interactive device provisioning process often includes collecting some extra dev
|
||||
|
||||
To skip this interactive step we need to buid this configuration once and save it to the JSON file for later reuse.
|
||||
|
||||
Let's say we will place it into the `CONFIG_FILE` path, like _./resin-os/raspberrypi3-config.json_.
|
||||
Let's say we will place it into the `CONFIG_FILE` path, like _./balena-os/raspberrypi3-config.json_.
|
||||
|
||||
We also need to put the OS image somewhere, let's call this path `OS_IMAGE_PATH`, it can be something like _./resin-os/raspberrypi3-v2.0.6+rev1.prod.img_.
|
||||
We also need to put the OS image somewhere, let's call this path `OS_IMAGE_PATH`, it can be something like _./balena-os/raspberrypi3-v2.0.6+rev1.prod.img_.
|
||||
|
||||
1. First we need to download the OS image once. That's needed for building the config, and will speedup the subsequent operations as the downloaded OS image is placed into the local cache.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
resin os download DEVICE_TYPE --output OS_IMAGE_PATH --version OS_VERSION
|
||||
balena os download DEVICE_TYPE --output OS_IMAGE_PATH --version OS_VERSION
|
||||
```
|
||||
|
||||
1. Now we're ready to build the config:
|
||||
|
||||
```bash
|
||||
resin os build-config OS_IMAGE_PATH DEVICE_TYPE --output CONFIG_FILE
|
||||
balena os build-config OS_IMAGE_PATH DEVICE_TYPE --output CONFIG_FILE
|
||||
```
|
||||
|
||||
This will run you through the interactive configuration wizard and in the end save the generated config as `CONFIG_FILE`. You can then verify it's not empty:
|
||||
@ -97,11 +97,11 @@ There are several ways to eliminate it and make the process fully non-interactiv
|
||||
|
||||
Obviously you shouldn't do that if the machine you're working on has access to any sensitive resources or information.
|
||||
|
||||
But if you're using a machine dedicated to resin provisioning this can be fine, and also the simplest thing to do.
|
||||
But if you're using a machine dedicated to balena provisioning this can be fine, and also the simplest thing to do.
|
||||
|
||||
#### Option 2: `NOPASSWD` directive
|
||||
|
||||
You can configure the `resin` CLI command to be sudo-runnable without the password. Check [this post](https://askubuntu.com/questions/159007/how-do-i-run-specific-sudo-commands-without-a-password) for an example.
|
||||
You can configure the `balena` CLI command to be sudo-runnable without the password. Check [this post](https://askubuntu.com/questions/159007/how-do-i-run-specific-sudo-commands-without-a-password) for an example.
|
||||
|
||||
### Extra initialization config
|
||||
|
||||
@ -109,4 +109,4 @@ As of June 2017 all the supported devices should not require any other interacti
|
||||
|
||||
But by the design of our system it is _possible_ (though it doesn't look very likely it's going to happen any time soon) that some extra initialization options may be requested for the specific device types.
|
||||
|
||||
If that is the case please raise the issue in the resin CLI repository and the maintainers will add the necessary options to build the similar JSON config for this step.
|
||||
If that is the case please raise the issue in the balena CLI repository and the maintainers will add the necessary options to build the similar JSON config for this step.
|
||||
|
1214
doc/cli.markdown
1214
doc/cli.markdown
File diff suppressed because it is too large
Load Diff
@ -1,46 +0,0 @@
|
||||
_ = require('lodash')
|
||||
path = require('path')
|
||||
capitanodoc = require('../../capitanodoc')
|
||||
markdown = require('./markdown')
|
||||
|
||||
result = {}
|
||||
result.title = capitanodoc.title
|
||||
result.introduction = capitanodoc.introduction
|
||||
result.categories = []
|
||||
|
||||
for commandCategory in capitanodoc.categories
|
||||
category = {}
|
||||
category.title = commandCategory.title
|
||||
category.commands = []
|
||||
|
||||
for file in commandCategory.files
|
||||
actions = require(path.join(process.cwd(), file))
|
||||
|
||||
if actions.signature?
|
||||
category.commands.push(_.omit(actions, 'action'))
|
||||
else
|
||||
for actionName, actionCommand of actions
|
||||
category.commands.push(_.omit(actionCommand, 'action'))
|
||||
|
||||
result.categories.push(category)
|
||||
|
||||
result.toc = _.cloneDeep(result.categories)
|
||||
result.toc = _.map result.toc, (category) ->
|
||||
category.commands = _.map category.commands, (command) ->
|
||||
return {
|
||||
signature: command.signature
|
||||
anchor: '#' + command.signature
|
||||
.replace(/\s/g,'-')
|
||||
.replace(/</g, '60-')
|
||||
.replace(/>/g, '-62-')
|
||||
.replace(/\[/g, '')
|
||||
.replace(/\]/g, '-')
|
||||
.replace(/--/g, '-')
|
||||
.replace(/\.\.\./g, '')
|
||||
.replace(/\|/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
return category
|
||||
|
||||
console.log(markdown.display(result))
|
@ -1,66 +0,0 @@
|
||||
_ = require('lodash')
|
||||
ent = require('ent')
|
||||
utils = require('./utils')
|
||||
|
||||
exports.command = (command) ->
|
||||
result = """
|
||||
## #{ent.encode(command.signature)}
|
||||
|
||||
#{command.help}\n
|
||||
"""
|
||||
|
||||
if not _.isEmpty(command.options)
|
||||
result += '\n### Options'
|
||||
|
||||
for option in command.options
|
||||
result += """
|
||||
\n\n#### #{utils.parseSignature(option)}
|
||||
|
||||
#{option.description}
|
||||
"""
|
||||
|
||||
result += '\n'
|
||||
|
||||
return result
|
||||
|
||||
exports.category = (category) ->
|
||||
result = """
|
||||
# #{category.title}\n
|
||||
"""
|
||||
|
||||
for command in category.commands
|
||||
result += '\n' + exports.command(command)
|
||||
|
||||
return result
|
||||
|
||||
exports.toc = (toc) ->
|
||||
result = '''
|
||||
# Table of contents\n
|
||||
'''
|
||||
|
||||
for category in toc
|
||||
|
||||
result += """
|
||||
\n- #{category.title}\n\n
|
||||
"""
|
||||
|
||||
for command in category.commands
|
||||
result += """
|
||||
\t- [#{ent.encode(command.signature)}](#{command.anchor})\n
|
||||
"""
|
||||
|
||||
return result
|
||||
|
||||
exports.display = (doc) ->
|
||||
result = """
|
||||
# #{doc.title}
|
||||
|
||||
#{doc.introduction}
|
||||
|
||||
#{exports.toc(doc.toc)}
|
||||
"""
|
||||
|
||||
for category in doc.categories
|
||||
result += '\n' + exports.category(category)
|
||||
|
||||
return result
|
@ -1,26 +0,0 @@
|
||||
_ = require('lodash')
|
||||
ent = require('ent')
|
||||
|
||||
exports.getOptionPrefix = (signature) ->
|
||||
if signature.length > 1
|
||||
return '--'
|
||||
else
|
||||
return '-'
|
||||
|
||||
exports.getOptionSignature = (signature) ->
|
||||
return "#{exports.getOptionPrefix(signature)}#{signature}"
|
||||
|
||||
exports.parseSignature = (option) ->
|
||||
result = exports.getOptionSignature(option.signature)
|
||||
|
||||
if not _.isEmpty(option.alias)
|
||||
if _.isString(option.alias)
|
||||
result += ", #{exports.getOptionSignature(option.alias)}"
|
||||
else
|
||||
for alias in option.alias
|
||||
result += ", #{exports.getOptionSignature(option.alias)}"
|
||||
|
||||
if option.parameter?
|
||||
result += " <#{option.parameter}>"
|
||||
|
||||
return ent.encode(result)
|
@ -1,15 +1,12 @@
|
||||
path = require('path')
|
||||
gulp = require('gulp')
|
||||
coffee = require('gulp-coffee')
|
||||
coffeelint = require('gulp-coffeelint')
|
||||
inlinesource = require('gulp-inline-source')
|
||||
mocha = require('gulp-mocha')
|
||||
shell = require('gulp-shell')
|
||||
packageJSON = require('./package.json')
|
||||
|
||||
OPTIONS =
|
||||
config:
|
||||
coffeelint: path.join(__dirname, 'coffeelint.json')
|
||||
files:
|
||||
coffee: [ 'lib/**/*.coffee', 'gulpfile.coffee' ]
|
||||
app: 'lib/**/*.coffee'
|
||||
@ -23,28 +20,21 @@ gulp.task 'pages', ->
|
||||
.pipe(inlinesource())
|
||||
.pipe(gulp.dest('build/auth/pages'))
|
||||
|
||||
gulp.task 'coffee', [ 'lint' ], ->
|
||||
gulp.task 'coffee', ->
|
||||
gulp.src(OPTIONS.files.app)
|
||||
.pipe(coffee(bare: true, header: true))
|
||||
.pipe(gulp.dest(OPTIONS.directories.build))
|
||||
|
||||
gulp.task 'lint', ->
|
||||
gulp.src(OPTIONS.files.coffee)
|
||||
.pipe(coffeelint({
|
||||
optFile: OPTIONS.config.coffeelint
|
||||
}))
|
||||
.pipe(coffeelint.reporter())
|
||||
|
||||
gulp.task 'test', ->
|
||||
gulp.src(OPTIONS.files.tests, read: false)
|
||||
.pipe(mocha({
|
||||
reporter: 'min'
|
||||
reporter: 'spec'
|
||||
}))
|
||||
|
||||
gulp.task 'build', [
|
||||
gulp.task 'build', gulp.series [
|
||||
'coffee',
|
||||
'pages'
|
||||
]
|
||||
|
||||
gulp.task 'watch', [ 'build' ], ->
|
||||
gulp.task 'watch', gulp.series [ 'build' ], ->
|
||||
gulp.watch([ OPTIONS.files.coffee ], [ 'build' ])
|
||||
|
36
lib/actions/api-key.ts
Normal file
36
lib/actions/api-key.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
export const generate: CommandDefinition<{
|
||||
name: string;
|
||||
}> = {
|
||||
signature: 'api-key generate <name>',
|
||||
description: 'Generate a new API key with the given name',
|
||||
help: stripIndent`
|
||||
This command generates a new API key for the current user, with the given
|
||||
name. The key will be logged to the console.
|
||||
|
||||
This key can be used to log into the CLI using 'balena login --token <key>',
|
||||
or to authenticate requests to the API with an 'Authorization: Bearer <key>' header.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena api-key generate "Jenkins Key"
|
||||
`,
|
||||
async action(params, _options, done) {
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
|
||||
balena.models.apiKey
|
||||
.create(params.name)
|
||||
.then(key => {
|
||||
console.log(stripIndent`
|
||||
Registered api key '${params.name}':
|
||||
|
||||
${key}
|
||||
|
||||
This key will not be shown again, so please save it now.
|
||||
`);
|
||||
})
|
||||
.finally(done);
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -20,44 +20,48 @@ exports.create =
|
||||
signature: 'app create <name>'
|
||||
description: 'create an application'
|
||||
help: '''
|
||||
Use this command to create a new resin.io application.
|
||||
Use this command to create a new balena application.
|
||||
|
||||
You can specify the application device type with the `--type` option.
|
||||
Otherwise, an interactive dropdown will be shown for you to select from.
|
||||
|
||||
You can see a list of supported device types with
|
||||
|
||||
$ resin devices supported
|
||||
$ balena devices supported
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin app create MyApp
|
||||
$ resin app create MyApp --type raspberry-pi
|
||||
$ balena app create MyApp
|
||||
$ balena app create MyApp --type raspberry-pi
|
||||
'''
|
||||
options: [
|
||||
{
|
||||
signature: 'type'
|
||||
parameter: 'type'
|
||||
description: 'application device type (Check available types with `resin devices supported`)'
|
||||
description: 'application device type (Check available types with `balena devices supported`)'
|
||||
alias: 't'
|
||||
}
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
# Validate the the application name is available
|
||||
# before asking the device type.
|
||||
# https://github.com/resin-io/resin-cli/issues/30
|
||||
resin.models.application.has(params.name).then (hasApplication) ->
|
||||
# https://github.com/balena-io/balena-cli/issues/30
|
||||
balena.models.application.has(params.name).then (hasApplication) ->
|
||||
if hasApplication
|
||||
throw new Error('You already have an application with that name!')
|
||||
patterns.exitWithExpectedError('You already have an application with that name!')
|
||||
|
||||
.then ->
|
||||
return options.type or patterns.selectDeviceType()
|
||||
.then (deviceType) ->
|
||||
return resin.models.application.create(params.name, deviceType)
|
||||
return balena.models.application.create({
|
||||
name: params.name
|
||||
deviceType
|
||||
})
|
||||
.then (application) ->
|
||||
console.info("Application created: #{application.app_name} (#{application.device_type}, id #{application.id})")
|
||||
.nodeify(done)
|
||||
@ -69,25 +73,37 @@ exports.list =
|
||||
Use this command to list all your applications.
|
||||
|
||||
Notice this command only shows the most important bits of information for each app.
|
||||
If you want detailed information, use resin app <name> instead.
|
||||
If you want detailed information, use balena app <name> instead.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin apps
|
||||
$ balena apps
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
_ = require('lodash')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.application.getAll().then (applications) ->
|
||||
balena.models.application.getAll
|
||||
$select: [
|
||||
'id'
|
||||
'app_name'
|
||||
'device_type'
|
||||
]
|
||||
$expand: owns__device: $select: 'is_online'
|
||||
.then (applications) ->
|
||||
applications.forEach (application) ->
|
||||
application.device_count = _.size(application.owns__device)
|
||||
application.online_devices = _.filter(application.owns__device, (d) -> d.is_online == true).length
|
||||
|
||||
console.log visuals.table.horizontal applications, [
|
||||
'id'
|
||||
'app_name'
|
||||
'device_type'
|
||||
'online_devices'
|
||||
'devices_length'
|
||||
'device_count'
|
||||
]
|
||||
.nodeify(done)
|
||||
|
||||
@ -99,15 +115,15 @@ exports.info =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin app MyApp
|
||||
$ balena app MyApp
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.application.get(params.name).then (application) ->
|
||||
balena.models.application.get(params.name).then (application) ->
|
||||
console.log visuals.table.vertical application, [
|
||||
"$#{application.app_name}$"
|
||||
'id'
|
||||
@ -125,33 +141,33 @@ exports.restart =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin app restart MyApp
|
||||
$ balena app restart MyApp
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.application.restart(params.name).nodeify(done)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.application.restart(params.name).nodeify(done)
|
||||
|
||||
exports.remove =
|
||||
signature: 'app rm <name>'
|
||||
description: 'remove an application'
|
||||
help: '''
|
||||
Use this command to remove a resin.io application.
|
||||
Use this command to remove a balena application.
|
||||
|
||||
Notice this command asks for confirmation interactively.
|
||||
You can avoid this by passing the `--yes` boolean option.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin app rm MyApp
|
||||
$ resin app rm MyApp --yes
|
||||
$ balena app rm MyApp
|
||||
$ balena app rm MyApp --yes
|
||||
'''
|
||||
options: [ commandOptions.yes ]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
patterns.confirm(options.yes, 'Are you sure you want to delete the application?').then ->
|
||||
resin.models.application.remove(params.name)
|
||||
balena.models.application.remove(params.name)
|
||||
.nodeify(done)
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -16,9 +16,9 @@ limitations under the License.
|
||||
|
||||
exports.login =
|
||||
signature: 'login'
|
||||
description: 'login to resin.io'
|
||||
description: 'login to balena'
|
||||
help: '''
|
||||
Use this command to login to your resin.io account.
|
||||
Use this command to login to your balena account.
|
||||
|
||||
This command will prompt you to login using the following login types:
|
||||
|
||||
@ -27,20 +27,20 @@ exports.login =
|
||||
|
||||
- Credentials: using email/password and 2FA.
|
||||
|
||||
- Token: using the authentication token from the preferences page.
|
||||
- Token: using a session token or API key from the preferences page.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin login
|
||||
$ resin login --web
|
||||
$ resin login --token "..."
|
||||
$ resin login --credentials
|
||||
$ resin login --credentials --email johndoe@gmail.com --password secret
|
||||
$ balena login
|
||||
$ balena login --web
|
||||
$ balena login --token "..."
|
||||
$ balena login --credentials
|
||||
$ balena login --credentials --email johndoe@gmail.com --password secret
|
||||
'''
|
||||
options: [
|
||||
{
|
||||
signature: 'token'
|
||||
description: 'auth token'
|
||||
description: 'session token or API key'
|
||||
parameter: 'token'
|
||||
alias: 't'
|
||||
}
|
||||
@ -57,23 +57,23 @@ exports.login =
|
||||
alias: 'c'
|
||||
}
|
||||
{
|
||||
signature: 'email'
|
||||
parameter: 'email'
|
||||
description: 'email'
|
||||
alias: [ 'e', 'u' ]
|
||||
}
|
||||
{
|
||||
signature: 'password'
|
||||
parameter: 'password'
|
||||
description: 'password'
|
||||
alias: 'p'
|
||||
}
|
||||
signature: 'email'
|
||||
parameter: 'email'
|
||||
description: 'email'
|
||||
alias: [ 'e', 'u' ]
|
||||
}
|
||||
{
|
||||
signature: 'password'
|
||||
parameter: 'password'
|
||||
description: 'password'
|
||||
alias: 'p'
|
||||
}
|
||||
]
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
_ = require('lodash')
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
auth = require('../auth')
|
||||
form = require('resin-cli-form')
|
||||
patterns = require('../utils/patterns')
|
||||
@ -84,10 +84,15 @@ exports.login =
|
||||
return Promise.try ->
|
||||
return options.token if _.isString(options.token)
|
||||
return form.ask
|
||||
message: 'Token (from the preferences page)'
|
||||
message: 'Session token or API key from the preferences page'
|
||||
name: 'token'
|
||||
type: 'input'
|
||||
.then(resin.auth.loginWithToken)
|
||||
.then(balena.auth.loginWithToken)
|
||||
.tap ->
|
||||
balena.auth.whoami()
|
||||
.then (username) ->
|
||||
if !username
|
||||
patterns.exitWithExpectedError('Token authentication failed')
|
||||
else if options.credentials
|
||||
return patterns.authenticate(options)
|
||||
else if options.web
|
||||
@ -97,24 +102,24 @@ exports.login =
|
||||
return patterns.askLoginType().then (loginType) ->
|
||||
|
||||
if loginType is 'register'
|
||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
||||
return capitanoRunAsync('signup')
|
||||
{ runCommand } = require('../utils/helpers')
|
||||
return runCommand('signup')
|
||||
|
||||
options[loginType] = true
|
||||
return login(options)
|
||||
|
||||
resin.settings.get('resinUrl').then (resinUrl) ->
|
||||
console.log(messages.resinAsciiArt)
|
||||
console.log("\nLogging in to #{resinUrl}")
|
||||
balena.settings.get('balenaUrl').then (balenaUrl) ->
|
||||
console.log(messages.balenaAsciiArt)
|
||||
console.log("\nLogging in to #{balenaUrl}")
|
||||
return login(options)
|
||||
.then(resin.auth.whoami)
|
||||
.then(balena.auth.whoami)
|
||||
.tap (username) ->
|
||||
console.info("Successfully logged in as: #{username}")
|
||||
console.info """
|
||||
|
||||
Find out about the available commands by running:
|
||||
|
||||
$ resin help
|
||||
$ balena help
|
||||
|
||||
#{messages.reachingOut}
|
||||
"""
|
||||
@ -122,43 +127,42 @@ exports.login =
|
||||
|
||||
exports.logout =
|
||||
signature: 'logout'
|
||||
description: 'logout from resin.io'
|
||||
description: 'logout from balena'
|
||||
help: '''
|
||||
Use this command to logout from your resin.io account.o
|
||||
Use this command to logout from your balena account.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin logout
|
||||
$ balena logout
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.auth.logout().nodeify(done)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.auth.logout().nodeify(done)
|
||||
|
||||
exports.signup =
|
||||
signature: 'signup'
|
||||
description: 'signup to resin.io'
|
||||
description: 'signup to balena'
|
||||
help: '''
|
||||
Use this command to signup for a resin.io account.
|
||||
Use this command to signup for a balena account.
|
||||
|
||||
If signup is successful, you'll be logged in to your new user automatically.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin signup
|
||||
$ balena signup
|
||||
Email: johndoe@acme.com
|
||||
Password: ***********
|
||||
|
||||
$ resin whoami
|
||||
$ balena whoami
|
||||
johndoe
|
||||
'''
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
form = require('resin-cli-form')
|
||||
validation = require('../utils/validation')
|
||||
|
||||
resin.settings.get('resinUrl').then (resinUrl) ->
|
||||
console.log("\nRegistering to #{resinUrl}")
|
||||
balena.settings.get('balenaUrl').then (balenaUrl) ->
|
||||
console.log("\nRegistering to #{balenaUrl}")
|
||||
|
||||
form.run [
|
||||
message: 'Email:'
|
||||
@ -172,8 +176,8 @@ exports.signup =
|
||||
validate: validation.validatePassword
|
||||
]
|
||||
|
||||
.then(resin.auth.register)
|
||||
.then(resin.auth.loginWithToken)
|
||||
.then(balena.auth.register)
|
||||
.then(balena.auth.loginWithToken)
|
||||
.nodeify(done)
|
||||
|
||||
exports.whoami =
|
||||
@ -184,18 +188,18 @@ exports.whoami =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin whoami
|
||||
$ balena whoami
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
Promise.props
|
||||
username: resin.auth.whoami()
|
||||
email: resin.auth.getEmail()
|
||||
url: resin.settings.get('resinUrl')
|
||||
username: balena.auth.whoami()
|
||||
email: balena.auth.getEmail()
|
||||
url: balena.settings.get('balenaUrl')
|
||||
.then (results) ->
|
||||
console.log visuals.table.vertical results, [
|
||||
'$account information$'
|
||||
|
@ -2,42 +2,83 @@
|
||||
# of this action
|
||||
Promise = require('bluebird')
|
||||
dockerUtils = require('../utils/docker')
|
||||
compose = require('../utils/compose')
|
||||
{ registrySecretsHelp } = require('../utils/messages')
|
||||
|
||||
getBundleInfo = Promise.method (options) ->
|
||||
helpers = require('../utils/helpers')
|
||||
###
|
||||
Opts must be an object with the following keys:
|
||||
|
||||
if options.application?
|
||||
# An application was provided
|
||||
return helpers.getAppInfo(options.application)
|
||||
.then (app) ->
|
||||
return [app.arch, app.device_type]
|
||||
else if options.arch? and options.deviceType?
|
||||
return [options.arch, options.deviceType]
|
||||
else
|
||||
# No information, cannot do resolution
|
||||
return undefined
|
||||
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
|
||||
###
|
||||
buildProject = (docker, logger, composeOpts, opts) ->
|
||||
compose.loadProject(
|
||||
logger
|
||||
composeOpts.projectPath
|
||||
composeOpts.projectName
|
||||
undefined # image: name of pre-built image
|
||||
composeOpts.dockerfilePath # ok if undefined
|
||||
)
|
||||
.then (project) ->
|
||||
appType = opts.app?.application_type?[0]
|
||||
if appType? and project.descriptors.length > 1 and not appType.supports_multicontainer
|
||||
logger.logWarn(
|
||||
'Target application does not support multiple containers.\n' +
|
||||
'Continuing with build, but you will not be able to deploy.'
|
||||
)
|
||||
|
||||
compose.buildProject(
|
||||
docker
|
||||
logger
|
||||
project.path
|
||||
project.name
|
||||
project.composition
|
||||
opts.arch
|
||||
opts.deviceType
|
||||
opts.buildEmulated
|
||||
opts.buildOpts
|
||||
composeOpts.inlineLogs
|
||||
)
|
||||
.then ->
|
||||
logger.logSuccess('Build succeeded!')
|
||||
.tapCatch (e) ->
|
||||
logger.logError('Build failed')
|
||||
|
||||
module.exports =
|
||||
signature: 'build [source]'
|
||||
description: 'Build a container locally'
|
||||
permission: 'user'
|
||||
help: '''
|
||||
Use this command to build a container with a provided docker daemon.
|
||||
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 resin Dockerfile pre-processor
|
||||
(e.g. Dockerfile.template -> Dockerfile).
|
||||
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. If it is found,
|
||||
this command will build each service defined in the compose file. 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.
|
||||
|
||||
#{registrySecretsHelp}
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin build
|
||||
$ resin build ./source/
|
||||
$ resin build --deviceType raspberrypi3 --arch armhf
|
||||
$ resin build --application MyApp ./source/
|
||||
$ resin build --docker '/var/run/docker.sock'
|
||||
$ resin build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem
|
||||
'''
|
||||
options: dockerUtils.appendOptions [
|
||||
$ balena build
|
||||
$ balena build ./source/
|
||||
$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated
|
||||
$ balena build --application MyApp ./source/
|
||||
$ balena build --docker '/var/run/docker.sock'
|
||||
$ 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'
|
||||
@ -53,12 +94,61 @@ module.exports =
|
||||
{
|
||||
signature: 'application'
|
||||
parameter: 'application'
|
||||
description: 'The target resin.io application this build is for'
|
||||
description: 'The target balena application this build is for'
|
||||
alias: 'a'
|
||||
},
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
# compositions with many services trigger misleading warnings
|
||||
require('events').defaultMaxListeners = 1000
|
||||
{ validateComposeOptions } = require('../utils/compose_ts')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
helpers = require('../utils/helpers')
|
||||
Logger = require('../utils/logger')
|
||||
dockerUtils.runBuild(params, options, getBundleInfo, new Logger())
|
||||
.asCallback(done)
|
||||
|
||||
logger = new Logger()
|
||||
|
||||
logger.logDebug('Parsing input...')
|
||||
|
||||
Promise.try ->
|
||||
# `build` accepts `[source]` as a parameter, but compose expects it
|
||||
# as an option. swap them here
|
||||
options.source ?= params.source
|
||||
delete params.source
|
||||
|
||||
validateComposeOptions(options)
|
||||
|
||||
{ application, arch, deviceType } = options
|
||||
|
||||
if (not (arch? and deviceType?) and not application?) or (application? and (arch? or deviceType?))
|
||||
exitWithExpectedError('You must specify either an application or an arch/deviceType pair to build for')
|
||||
|
||||
if arch? and deviceType?
|
||||
[ undefined, arch, deviceType ]
|
||||
else
|
||||
Promise.join(
|
||||
helpers.getApplication(application)
|
||||
helpers.getArchAndDeviceType(application)
|
||||
(app, { arch, device_type }) ->
|
||||
app.arch = arch
|
||||
app.device_type = device_type
|
||||
return app
|
||||
)
|
||||
.then (app) ->
|
||||
[ app, app.arch, app.device_type ]
|
||||
|
||||
.then ([ app, arch, deviceType ]) ->
|
||||
Promise.join(
|
||||
dockerUtils.getDocker(options)
|
||||
dockerUtils.generateBuildOpts(options)
|
||||
compose.generateOpts(options)
|
||||
(docker, buildOpts, composeOpts) ->
|
||||
buildProject(docker, logger, composeOpts, {
|
||||
app
|
||||
arch
|
||||
deviceType
|
||||
buildEmulated: !!options.emulated
|
||||
buildOpts
|
||||
})
|
||||
)
|
||||
.asCallback(done)
|
||||
|
@ -1,100 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
_ = require('lodash')
|
||||
|
||||
exports.yes =
|
||||
signature: 'yes'
|
||||
description: 'confirm non interactively'
|
||||
boolean: true
|
||||
alias: 'y'
|
||||
|
||||
exports.optionalApplication =
|
||||
signature: 'application'
|
||||
parameter: 'application'
|
||||
description: 'application name'
|
||||
alias: [ 'a', 'app' ]
|
||||
|
||||
exports.application = _.defaults
|
||||
required: 'You have to specify an application'
|
||||
, exports.optionalApplication
|
||||
|
||||
exports.optionalDevice =
|
||||
signature: 'device'
|
||||
parameter: 'device'
|
||||
description: 'device uuid'
|
||||
alias: 'd'
|
||||
|
||||
exports.optionalDeviceApiKey =
|
||||
signature: 'deviceApiKey'
|
||||
description: 'custom device key - note that this is only supported on ResinOS 2.0.3+'
|
||||
parameter: 'device-api-key'
|
||||
alias: 'k'
|
||||
|
||||
exports.booleanDevice =
|
||||
signature: 'device'
|
||||
description: 'device'
|
||||
boolean: true
|
||||
alias: 'd'
|
||||
|
||||
exports.osVersion =
|
||||
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'
|
||||
|
||||
exports.network =
|
||||
signature: 'network'
|
||||
parameter: 'network'
|
||||
description: 'network type'
|
||||
alias: 'n'
|
||||
|
||||
exports.wifiSsid =
|
||||
signature: 'ssid'
|
||||
parameter: 'ssid'
|
||||
description: 'wifi ssid, if network is wifi'
|
||||
alias: 's'
|
||||
|
||||
exports.wifiKey =
|
||||
signature: 'key'
|
||||
parameter: 'key'
|
||||
description: 'wifi key, if network is wifi'
|
||||
alias: 'k'
|
||||
|
||||
exports.forceUpdateLock =
|
||||
signature: 'force'
|
||||
description: 'force action if the update lock is set'
|
||||
boolean: true
|
||||
alias: 'f'
|
||||
|
||||
exports.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 `resin util available-drives` for available options.'
|
||||
parameter: 'drive'
|
||||
alias: 'd'
|
||||
|
||||
exports.advancedConfig =
|
||||
signature: 'advanced'
|
||||
description: 'show advanced configuration options'
|
||||
boolean: true
|
||||
alias: 'v'
|
147
lib/actions/command-options.ts
Normal file
147
lib/actions/command-options.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import _ = require('lodash');
|
||||
|
||||
export const yes = {
|
||||
signature: 'yes',
|
||||
description: 'confirm non interactively',
|
||||
boolean: true,
|
||||
alias: 'y',
|
||||
};
|
||||
|
||||
export const optionalApplication = {
|
||||
signature: 'application',
|
||||
parameter: 'application',
|
||||
description: 'application name',
|
||||
alias: ['a', 'app'],
|
||||
};
|
||||
|
||||
export const application = _.defaults(
|
||||
{ required: 'You have to specify an application' },
|
||||
optionalApplication,
|
||||
);
|
||||
|
||||
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 const osVersion = _.defaults(
|
||||
{
|
||||
required: 'You have to specify an exact os version',
|
||||
},
|
||||
exports.optionalOsVersion,
|
||||
);
|
||||
|
||||
export const booleanDevice = {
|
||||
signature: 'device',
|
||||
description: 'device',
|
||||
boolean: true,
|
||||
alias: 'd',
|
||||
};
|
||||
|
||||
export const osVersionOrSemver = {
|
||||
signature: 'version',
|
||||
description: `\
|
||||
exact version number, or a valid semver range,
|
||||
or 'latest' (includes pre-releases),
|
||||
or 'default' (excludes pre-releases if at least one stable version is available),
|
||||
or 'recommended' (excludes pre-releases, will fail if only pre-release versions are available),
|
||||
or 'menu' (will show the interactive menu)\
|
||||
`,
|
||||
parameter: 'version',
|
||||
};
|
||||
|
||||
export const network = {
|
||||
signature: 'network',
|
||||
parameter: 'network',
|
||||
description: 'network type',
|
||||
alias: 'n',
|
||||
};
|
||||
|
||||
export const wifiSsid = {
|
||||
signature: 'ssid',
|
||||
parameter: 'ssid',
|
||||
description: 'wifi ssid, if network is wifi',
|
||||
alias: 's',
|
||||
};
|
||||
|
||||
export const wifiKey = {
|
||||
signature: 'key',
|
||||
parameter: 'key',
|
||||
description: 'wifi key, if network is wifi',
|
||||
alias: 'k',
|
||||
};
|
||||
|
||||
export const forceUpdateLock = {
|
||||
signature: 'force',
|
||||
description: 'force action if the update lock is set',
|
||||
boolean: true,
|
||||
alias: 'f',
|
||||
};
|
||||
|
||||
export const drive = {
|
||||
signature: 'drive',
|
||||
description: `the drive to write the image to, like \`/dev/sdb\` or \`/dev/mmcblk0\`. \
|
||||
Careful with this as you can erase your hard drive. \
|
||||
Check \`balena util available-drives\` for available options.`,
|
||||
parameter: 'drive',
|
||||
alias: 'd',
|
||||
};
|
||||
|
||||
export const advancedConfig = {
|
||||
signature: 'advanced',
|
||||
description: 'show advanced configuration options',
|
||||
boolean: true,
|
||||
alias: 'v',
|
||||
};
|
||||
|
||||
export const hostOSAccess = {
|
||||
signature: 'host',
|
||||
boolean: true,
|
||||
description: 'access host OS (for devices with balenaOS >= 2.0.0+rev1)',
|
||||
alias: 's',
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
Copyright 2016-2018 Balena Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -15,6 +15,7 @@ limitations under the License.
|
||||
###
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
exports.read =
|
||||
signature: 'config read'
|
||||
@ -24,13 +25,13 @@ exports.read =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin config read --type raspberry-pi
|
||||
$ resin config read --type raspberry-pi --drive /dev/disk2
|
||||
$ 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 `resin devices supported`)'
|
||||
description: 'device type (Check available types with `balena devices supported`)'
|
||||
parameter: 'type'
|
||||
alias: 't'
|
||||
required: 'You have to specify a device type'
|
||||
@ -46,7 +47,7 @@ exports.read =
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
config = require('resin-config-json')
|
||||
config = require('balena-config-json')
|
||||
visuals = require('resin-cli-visuals')
|
||||
umountAsync = Promise.promisify(require('umount').umount)
|
||||
prettyjson = require('prettyjson')
|
||||
@ -68,14 +69,14 @@ exports.write =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin config write --type raspberry-pi username johndoe
|
||||
$ resin config write --type raspberry-pi --drive /dev/disk2 username johndoe
|
||||
$ resin config write --type raspberry-pi files.network/settings "..."
|
||||
$ 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 `resin devices supported`)'
|
||||
description: 'device type (Check available types with `balena devices supported`)'
|
||||
parameter: 'type'
|
||||
alias: 't'
|
||||
required: 'You have to specify a device type'
|
||||
@ -92,7 +93,7 @@ exports.write =
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
config = require('resin-config-json')
|
||||
config = require('balena-config-json')
|
||||
visuals = require('resin-cli-visuals')
|
||||
umountAsync = Promise.promisify(require('umount').umount)
|
||||
|
||||
@ -116,17 +117,18 @@ exports.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) of a provisioned device"
|
||||
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:
|
||||
|
||||
$ resin config inject my/config.json --type raspberry-pi
|
||||
$ resin config inject my/config.json --type raspberry-pi --drive /dev/disk2
|
||||
$ 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 `resin devices supported`)'
|
||||
description: 'device type (Check available types with `balena devices supported`)'
|
||||
parameter: 'type'
|
||||
alias: 't'
|
||||
required: 'You have to specify a device type'
|
||||
@ -142,7 +144,7 @@ exports.inject =
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
config = require('resin-config-json')
|
||||
config = require('balena-config-json')
|
||||
visuals = require('resin-cli-visuals')
|
||||
umountAsync = Promise.promisify(require('umount').umount)
|
||||
readFileAsync = Promise.promisify(require('fs').readFile)
|
||||
@ -165,14 +167,14 @@ exports.reconfigure =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin config reconfigure --type raspberry-pi
|
||||
$ resin config reconfigure --type raspberry-pi --advanced
|
||||
$ resin config reconfigure --type raspberry-pi --drive /dev/disk2
|
||||
$ 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 `resin devices supported`)'
|
||||
description: 'device type (Check available types with `balena devices supported`)'
|
||||
parameter: 'type'
|
||||
alias: 't'
|
||||
required: 'You have to specify a device type'
|
||||
@ -194,9 +196,9 @@ exports.reconfigure =
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
config = require('resin-config-json')
|
||||
config = require('balena-config-json')
|
||||
visuals = require('resin-cli-visuals')
|
||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
||||
{ runCommand } = require('../utils/helpers')
|
||||
umountAsync = Promise.promisify(require('umount').umount)
|
||||
|
||||
Promise.try ->
|
||||
@ -207,10 +209,10 @@ exports.reconfigure =
|
||||
.tap ->
|
||||
umountAsync(drive)
|
||||
.then (uuid) ->
|
||||
configureCommand = "os configure #{drive} #{uuid}"
|
||||
configureCommand = "os configure #{drive} --device #{uuid}"
|
||||
if options.advanced
|
||||
configureCommand += ' --advanced'
|
||||
return capitanoRunAsync(configureCommand)
|
||||
return runCommand(configureCommand)
|
||||
.then ->
|
||||
console.info('Done')
|
||||
.nodeify(done)
|
||||
@ -221,23 +223,38 @@ exports.generate =
|
||||
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:
|
||||
|
||||
$ resin config generate --device 7cf02a6
|
||||
$ resin config generate --device 7cf02a6 --device-api-key <existingDeviceKey>
|
||||
$ resin config generate --device 7cf02a6 --output config.json
|
||||
$ resin config generate --app MyApp
|
||||
$ resin config generate --app MyApp --output config.json
|
||||
$ resin config generate --app MyApp --network wifi --wifiSsid mySsid --wifiKey abcdefgh --appUpdatePollInterval 1
|
||||
$ 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'
|
||||
@ -268,42 +285,71 @@ exports.generate =
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(options, 'device')
|
||||
Promise = require('bluebird')
|
||||
writeFileAsync = Promise.promisify(require('fs').writeFile)
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
_ = require('lodash')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
form = require('resin-cli-form')
|
||||
deviceConfig = require('resin-device-config')
|
||||
prettyjson = require('prettyjson')
|
||||
|
||||
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
|
||||
helpers = require('../utils/helpers')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
if not options.device? and not options.application?
|
||||
throw new Error '''
|
||||
exitWithExpectedError '''
|
||||
You have to pass either a device or an application.
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ resin help config generate
|
||||
$ balena help config generate
|
||||
'''
|
||||
|
||||
if !options.application and 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
|
||||
'''
|
||||
|
||||
Promise.try ->
|
||||
if options.device?
|
||||
return resin.models.device.get(options.device)
|
||||
return resin.models.application.get(options.application)
|
||||
return balena.models.device.get(options.device)
|
||||
return balena.models.application.get(options.application)
|
||||
.then (resource) ->
|
||||
resin.models.device.getManifestBySlug(resource.device_type)
|
||||
.get('options')
|
||||
deviceType = options.deviceType || resource.device_type
|
||||
manifestPromise = balena.models.device.getManifestBySlug(deviceType)
|
||||
|
||||
if options.application && options.deviceType
|
||||
app = resource
|
||||
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
|
||||
manifestPromise = manifestPromise.tap (paramDeviceType) ->
|
||||
appManifestPromise.then (appDeviceType) ->
|
||||
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
|
||||
throw new balena.errors.BalenaInvalidDeviceType(
|
||||
"Device type #{options.deviceType} is incompatible with application #{options.application}"
|
||||
)
|
||||
|
||||
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)
|
||||
form.run(formOptions, override: options)
|
||||
.then (answers) ->
|
||||
answers.version = options.version
|
||||
|
||||
if resource.uuid?
|
||||
generateDeviceConfig(resource, options.deviceApiKey, answers)
|
||||
generateDeviceConfig(resource, options.deviceApiKey || options['generate-device-api-key'], answers)
|
||||
else
|
||||
answers.deviceType = deviceType
|
||||
generateApplicationConfig(resource, answers)
|
||||
.then (config) ->
|
||||
deviceConfig.validate(config)
|
||||
if options.output?
|
||||
return writeFileAsync(options.output, JSON.stringify(config))
|
||||
|
||||
|
@ -1,145 +1,176 @@
|
||||
# Imported here because it's needed for the setup
|
||||
# of this action
|
||||
Promise = require('bluebird')
|
||||
dockerUtils = require('../utils/docker')
|
||||
compose = require('../utils/compose')
|
||||
{ registrySecretsHelp } = require('../utils/messages')
|
||||
|
||||
getBuilderPushEndpoint = (baseUrl, owner, app) ->
|
||||
querystring = require('querystring')
|
||||
args = querystring.stringify({ owner, app })
|
||||
"https://builder.#{baseUrl}/v1/push?#{args}"
|
||||
###
|
||||
Opts must be an object with the following keys:
|
||||
|
||||
getBuilderLogPushEndpoint = (baseUrl, buildId, owner, app) ->
|
||||
querystring = require('querystring')
|
||||
args = querystring.stringify({ owner, app, buildId })
|
||||
"https://builder.#{baseUrl}/v1/pushLogs?#{args}"
|
||||
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
|
||||
###
|
||||
deployProject = (docker, logger, composeOpts, opts) ->
|
||||
_ = require('lodash')
|
||||
doodles = require('resin-doodles')
|
||||
sdk = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
formatImageName = (image) ->
|
||||
image.split('/').pop()
|
||||
compose.loadProject(
|
||||
logger
|
||||
composeOpts.projectPath
|
||||
composeOpts.projectName
|
||||
opts.image
|
||||
composeOpts.dockerfilePath # ok if undefined
|
||||
)
|
||||
.then (project) ->
|
||||
if project.descriptors.length > 1 and !opts.app.application_type?[0]?.supports_multicontainer
|
||||
throw new Error('Target application does not support multiple containers. Aborting!')
|
||||
|
||||
parseInput = Promise.method (params, options) ->
|
||||
if not params.appName?
|
||||
throw new Error('Need an application to deploy to!')
|
||||
appName = params.appName
|
||||
image = undefined
|
||||
if params.image?
|
||||
if options.build or options.source?
|
||||
throw new Error('Build and source parameters are not applicable when specifying an image')
|
||||
options.build = false
|
||||
image = params.image
|
||||
else if options.build
|
||||
source = options.source || '.'
|
||||
else
|
||||
throw new Error('Need either an image or a build flag!')
|
||||
# find which services use images that already exist locally
|
||||
Promise.map project.descriptors, (d) ->
|
||||
# unconditionally build (or pull) if explicitly requested
|
||||
return d if opts.shouldPerformBuild
|
||||
docker.getImage(d.image.tag ? d.image).inspect()
|
||||
.return(d.serviceName)
|
||||
.catchReturn()
|
||||
.filter (d) -> !!d
|
||||
.then (servicesToSkip) ->
|
||||
# multibuild takes in a composition and always attempts to
|
||||
# build or pull all services. we workaround that here by
|
||||
# passing a modified composition.
|
||||
compositionToBuild = _.cloneDeep(project.composition)
|
||||
compositionToBuild.services = _.omit(compositionToBuild.services, servicesToSkip)
|
||||
if _.size(compositionToBuild.services) is 0
|
||||
logger.logInfo('Everything is up to date (use --build to force a rebuild)')
|
||||
return {}
|
||||
compose.buildProject(
|
||||
docker
|
||||
logger
|
||||
project.path
|
||||
project.name
|
||||
compositionToBuild
|
||||
opts.app.arch
|
||||
opts.app.device_type
|
||||
opts.buildEmulated
|
||||
opts.buildOpts
|
||||
composeOpts.inlineLogs
|
||||
)
|
||||
.then (builtImages) ->
|
||||
_.keyBy(builtImages, 'serviceName')
|
||||
.then (builtImages) ->
|
||||
project.descriptors.map (d) ->
|
||||
builtImages[d.serviceName] ? {
|
||||
serviceName: d.serviceName,
|
||||
name: d.image.tag ? d.image
|
||||
logs: 'Build skipped; image for service already exists.'
|
||||
props: {}
|
||||
}
|
||||
.then (images) ->
|
||||
if opts.app.application_type?[0]?.is_legacy
|
||||
chalk = require('chalk')
|
||||
legacyDeploy = require('../utils/deploy-legacy')
|
||||
|
||||
return [appName, options.build, source, image]
|
||||
msg = chalk.yellow('Target application requires legacy deploy method.')
|
||||
logger.logWarn(msg)
|
||||
|
||||
showPushProgress = (message) ->
|
||||
visuals = require('resin-cli-visuals')
|
||||
progressBar = new visuals.Progress(message)
|
||||
progressBar.update({ percentage: 0 })
|
||||
return progressBar
|
||||
|
||||
getBundleInfo = (options) ->
|
||||
helpers = require('../utils/helpers')
|
||||
|
||||
helpers.getAppInfo(options.appName)
|
||||
.then (app) ->
|
||||
[app.arch, app.device_type]
|
||||
|
||||
performUpload = (imageStream, token, username, url, appName, logger) ->
|
||||
request = require('request')
|
||||
progressStream = require('progress-stream')
|
||||
zlib = require('zlib')
|
||||
|
||||
# Need to strip off the newline
|
||||
progressMessage = logger.formatMessage('info', 'Deploying').slice(0, -1)
|
||||
progressBar = showPushProgress(progressMessage)
|
||||
streamWithProgress = imageStream.pipe progressStream
|
||||
time: 500,
|
||||
length: imageStream.length
|
||||
, ({ percentage, eta }) ->
|
||||
progressBar.update
|
||||
percentage: Math.min(percentage, 100)
|
||||
eta: eta
|
||||
|
||||
uploadRequest = request.post
|
||||
url: getBuilderPushEndpoint(url, username, appName)
|
||||
headers:
|
||||
'Content-Encoding': 'gzip'
|
||||
auth:
|
||||
bearer: token
|
||||
body: streamWithProgress.pipe(zlib.createGzip({
|
||||
level: 6
|
||||
}))
|
||||
|
||||
uploadToPromise(uploadRequest, logger)
|
||||
|
||||
uploadLogs = (logs, token, url, buildId, username, appName) ->
|
||||
request = require('request')
|
||||
request.post
|
||||
json: true
|
||||
url: getBuilderLogPushEndpoint(url, buildId, username, appName)
|
||||
auth:
|
||||
bearer: token
|
||||
body: Buffer.from(logs)
|
||||
|
||||
uploadToPromise = (uploadRequest, logger) ->
|
||||
new Promise (resolve, reject) ->
|
||||
|
||||
handleMessage = (data) ->
|
||||
data = data.toString()
|
||||
logger.logDebug("Received data: #{data}")
|
||||
|
||||
try
|
||||
obj = JSON.parse(data)
|
||||
catch e
|
||||
logger.logError('Error parsing reply from remote side')
|
||||
reject(e)
|
||||
return
|
||||
|
||||
if obj.type?
|
||||
switch obj.type
|
||||
when 'error' then reject(new Error("Remote error: #{obj.error}"))
|
||||
when 'success' then resolve(obj)
|
||||
when 'status' then logger.logInfo("Remote: #{obj.message}")
|
||||
else reject(new Error("Received unexpected reply from remote: #{data}"))
|
||||
else
|
||||
reject(new Error("Received unexpected reply from remote: #{data}"))
|
||||
|
||||
uploadRequest
|
||||
.on('error', reject)
|
||||
.on('data', handleMessage)
|
||||
return Promise.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
|
||||
}
|
||||
legacyDeploy
|
||||
)
|
||||
.then (releaseId) ->
|
||||
sdk.models.release.get(releaseId, $select: [ 'commit' ])
|
||||
Promise.join(
|
||||
sdk.auth.getUserId()
|
||||
sdk.auth.getToken()
|
||||
sdk.settings.get('apiUrl')
|
||||
(userId, auth, apiEndpoint) ->
|
||||
compose.deployProject(
|
||||
docker
|
||||
logger
|
||||
project.composition
|
||||
images
|
||||
opts.app.id
|
||||
userId
|
||||
"Bearer #{auth}"
|
||||
apiEndpoint
|
||||
!opts.shouldUploadLogs
|
||||
)
|
||||
)
|
||||
.then (release) ->
|
||||
logger.logSuccess('Deploy succeeded!')
|
||||
logger.logSuccess("Release: #{release.commit}")
|
||||
console.log()
|
||||
console.log(doodles.getDoodle()) # Show charlie
|
||||
console.log()
|
||||
.tapCatch (e) ->
|
||||
logger.logError('Deploy failed')
|
||||
|
||||
module.exports =
|
||||
signature: 'deploy <appName> [image]'
|
||||
description: 'Deploy an image to a resin.io application'
|
||||
help: '''
|
||||
Use this command to deploy an image to an application, optionally building it first.
|
||||
|
||||
description: 'Deploy a single image or a multicontainer project to a balena application'
|
||||
help: """
|
||||
Usage: `deploy <appName> ([image] | --build [--source build-dir])`
|
||||
|
||||
To deploy to an app on which you're a collaborator, use
|
||||
`resin deploy <appOwnerUsername>/<appName>`.
|
||||
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.)
|
||||
|
||||
Note: If building with this command, all options supported by `resin build`
|
||||
are also supported with this command.
|
||||
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}
|
||||
|
||||
Examples:
|
||||
$ resin deploy myApp --build --source myBuildDir/
|
||||
$ resin deploy myApp myApp/myImage
|
||||
'''
|
||||
|
||||
$ balena deploy myApp
|
||||
$ balena deploy myApp --build --source myBuildDir/
|
||||
$ balena deploy myApp myApp/myImage
|
||||
"""
|
||||
permission: 'user'
|
||||
options: dockerUtils.appendOptions [
|
||||
{
|
||||
signature: 'build'
|
||||
boolean: true
|
||||
description: 'Build image then deploy'
|
||||
alias: 'b'
|
||||
},
|
||||
primary: true
|
||||
options: dockerUtils.appendOptions compose.appendOptions [
|
||||
{
|
||||
signature: 'source'
|
||||
parameter: 'source'
|
||||
description: 'The source directory to use when building the image'
|
||||
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)"
|
||||
@ -147,83 +178,59 @@ module.exports =
|
||||
}
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
_ = require('lodash')
|
||||
tmp = require('tmp')
|
||||
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
# compositions with many services trigger misleading warnings
|
||||
require('events').defaultMaxListeners = 1000
|
||||
{ validateComposeOptions } = require('../utils/compose_ts')
|
||||
helpers = require('../utils/helpers')
|
||||
Logger = require('../utils/logger')
|
||||
|
||||
logger = new Logger()
|
||||
|
||||
# Ensure the tmp files gets deleted
|
||||
tmp.setGracefulCleanup()
|
||||
logger.logDebug('Parsing input...')
|
||||
|
||||
logs = ''
|
||||
appName = undefined
|
||||
Promise.try ->
|
||||
# when Capitano converts a positional parameter (but not an option)
|
||||
# to a number, the original value is preserved with the _raw suffix
|
||||
{ appName, appName_raw, image } = params
|
||||
|
||||
upload = (token, username, url) ->
|
||||
dockerUtils.getDocker(options)
|
||||
.then (docker) ->
|
||||
# Check input parameters
|
||||
parseInput(params, options)
|
||||
.then ([appName, build, source, imageName]) ->
|
||||
tmpNameAsync()
|
||||
.then (bufferFile) ->
|
||||
# look into "balena build" options if appName isn't given
|
||||
appName = appName_raw || appName || options.application
|
||||
delete options.application
|
||||
|
||||
# Setup the build args for how the build routine expects them
|
||||
options = _.assign({}, options, { appName })
|
||||
params = _.assign({}, params, { source })
|
||||
validateComposeOptions(options)
|
||||
|
||||
Promise.try ->
|
||||
if build
|
||||
dockerUtils.runBuild(params, options, getBundleInfo, logger)
|
||||
else
|
||||
{ image: imageName, log: '' }
|
||||
.then ({ image: imageName, log: buildLogs }) ->
|
||||
logger.logInfo('Initializing deploy...')
|
||||
if not appName?
|
||||
throw new Error('Please specify the name of the application to deploy')
|
||||
|
||||
logs = buildLogs
|
||||
Promise.all [
|
||||
dockerUtils.bufferImage(docker, imageName, bufferFile)
|
||||
token
|
||||
username
|
||||
url
|
||||
params.appName
|
||||
logger
|
||||
]
|
||||
.spread(performUpload)
|
||||
.finally ->
|
||||
# If the file was never written to (for instance because an error
|
||||
# has occured before any data was written) this call will throw an
|
||||
# ugly error, just suppress it
|
||||
Promise.try ->
|
||||
require('mz/fs').unlink(bufferFile)
|
||||
.catch(_.noop)
|
||||
.tap ({ image: imageName, buildId }) ->
|
||||
logger.logSuccess("Successfully deployed image: #{formatImageName(imageName)}")
|
||||
return buildId
|
||||
.then ({ image: imageName, buildId }) ->
|
||||
if logs is '' or options.nologupload?
|
||||
return ''
|
||||
if image? and options.build
|
||||
throw new Error('Build option is not applicable when specifying an image')
|
||||
|
||||
logger.logInfo('Uploading logs to dashboard...')
|
||||
Promise.join(
|
||||
helpers.getApplication(appName)
|
||||
helpers.getArchAndDeviceType(appName)
|
||||
(app, { arch, device_type }) ->
|
||||
app.arch = arch
|
||||
app.device_type = device_type
|
||||
return app
|
||||
)
|
||||
.then (app) ->
|
||||
[ app, image, !!options.build, !options.nologupload ]
|
||||
|
||||
Promise.join(
|
||||
logs
|
||||
token
|
||||
url
|
||||
buildId
|
||||
username
|
||||
params.appName
|
||||
uploadLogs
|
||||
)
|
||||
.return('Successfully uploaded logs')
|
||||
.then (msg) ->
|
||||
logger.logSuccess(msg) if msg isnt ''
|
||||
.asCallback(done)
|
||||
|
||||
Promise.join(
|
||||
resin.auth.getToken()
|
||||
resin.auth.whoami()
|
||||
resin.settings.get('resinUrl')
|
||||
upload
|
||||
)
|
||||
.then ([ app, image, shouldPerformBuild, shouldUploadLogs ]) ->
|
||||
Promise.join(
|
||||
dockerUtils.getDocker(options)
|
||||
dockerUtils.generateBuildOpts(options)
|
||||
compose.generateOpts(options)
|
||||
(docker, buildOpts, composeOpts) ->
|
||||
deployProject(docker, logger, composeOpts, {
|
||||
app
|
||||
appName # may be prefixed by 'owner/', unlike app.app_name
|
||||
image
|
||||
shouldPerformBuild
|
||||
shouldUploadLogs
|
||||
buildEmulated: !!options.emulated
|
||||
buildOpts
|
||||
})
|
||||
)
|
||||
.asCallback(done)
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -16,6 +16,11 @@ limitations under the License.
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
_ = require('lodash')
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
expandForAppName = {
|
||||
$expand: belongs_to__application: $select: 'app_name'
|
||||
}
|
||||
|
||||
exports.list =
|
||||
signature: 'devices'
|
||||
@ -27,33 +32,35 @@ exports.list =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin devices
|
||||
$ resin devices --application MyApp
|
||||
$ resin devices --app MyApp
|
||||
$ resin devices -a MyApp
|
||||
$ balena devices
|
||||
$ balena devices --application MyApp
|
||||
$ balena devices --app MyApp
|
||||
$ balena devices -a MyApp
|
||||
'''
|
||||
options: [ commandOptions.optionalApplication ]
|
||||
permission: 'user'
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
Promise.try ->
|
||||
if options.application?
|
||||
return resin.models.device.getAllByApplication(options.application)
|
||||
return resin.models.device.getAll()
|
||||
return balena.models.device.getAllByApplication(options.application, expandForAppName)
|
||||
return balena.models.device.getAll(expandForAppName)
|
||||
|
||||
.tap (devices) ->
|
||||
devices = _.map devices, (device) ->
|
||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
|
||||
device.application_name = device.belongs_to__application[0].app_name
|
||||
device.uuid = device.uuid.slice(0, 7)
|
||||
return device
|
||||
|
||||
console.log visuals.table.horizontal devices, [
|
||||
'id'
|
||||
'uuid'
|
||||
'name'
|
||||
'device_name'
|
||||
'device_type'
|
||||
'application_name'
|
||||
'status'
|
||||
@ -72,21 +79,25 @@ exports.info =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device 7cf02a6
|
||||
$ balena device 7cf02a6
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.device.get(params.uuid).then (device) ->
|
||||
|
||||
resin.models.device.getStatus(device).then (status) ->
|
||||
balena.models.device.get(params.uuid, expandForAppName)
|
||||
.then (device) ->
|
||||
balena.models.device.getStatus(device).then (status) ->
|
||||
device.status = status
|
||||
device.dashboard_url = balena.models.device.getDashboardUrl(device.uuid)
|
||||
device.application_name = device.belongs_to__application[0].app_name
|
||||
device.commit = device.is_on__commit
|
||||
|
||||
console.log visuals.table.vertical device, [
|
||||
"$#{device.name}$"
|
||||
"$#{device.device_name}$"
|
||||
'id'
|
||||
'device_type'
|
||||
'status'
|
||||
@ -112,13 +123,13 @@ exports.supported =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin devices supported
|
||||
$ balena devices supported
|
||||
'''
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.config.getDeviceTypes().then (deviceTypes) ->
|
||||
balena.models.config.getDeviceTypes().then (deviceTypes) ->
|
||||
console.log visuals.table.horizontal deviceTypes, [
|
||||
'slug'
|
||||
'name'
|
||||
@ -131,13 +142,10 @@ exports.register =
|
||||
help: '''
|
||||
Use this command to register a device to an application.
|
||||
|
||||
Note that device api keys are only supported on ResinOS 2.0.3+
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device register MyApp
|
||||
$ resin device register MyApp --uuid <uuid>
|
||||
$ resin device register MyApp --uuid <uuid> --device-api-key <existingDeviceKey>
|
||||
$ balena device register MyApp
|
||||
$ balena device register MyApp --uuid <uuid>
|
||||
'''
|
||||
permission: 'user'
|
||||
options: [
|
||||
@ -147,23 +155,17 @@ exports.register =
|
||||
parameter: 'uuid'
|
||||
alias: 'u'
|
||||
}
|
||||
commandOptions.optionalDeviceApiKey
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
Promise.join(
|
||||
resin.models.application.get(params.application)
|
||||
options.uuid ? resin.models.device.generateUniqueKey()
|
||||
options.deviceApiKey ? resin.models.device.generateUniqueKey()
|
||||
(application, uuid, deviceApiKey) ->
|
||||
balena.models.application.get(params.application)
|
||||
options.uuid ? balena.models.device.generateUniqueKey()
|
||||
(application, uuid) ->
|
||||
console.info("Registering to #{application.app_name}: #{uuid}")
|
||||
if not options.deviceApiKey?
|
||||
console.info("Using generated device api key: #{deviceApiKey}")
|
||||
else
|
||||
console.info('Using provided device api key')
|
||||
return resin.models.device.register(application.id, uuid, deviceApiKey)
|
||||
return balena.models.device.register(application.id, uuid)
|
||||
)
|
||||
.get('uuid')
|
||||
.nodeify(done)
|
||||
@ -172,24 +174,25 @@ exports.remove =
|
||||
signature: 'device rm <uuid>'
|
||||
description: 'remove a device'
|
||||
help: '''
|
||||
Use this command to remove a device from resin.io.
|
||||
Use this command to remove a device from balena.
|
||||
|
||||
Notice this command asks for confirmation interactively.
|
||||
You can avoid this by passing the `--yes` boolean option.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device rm 7cf02a6
|
||||
$ resin device rm 7cf02a6 --yes
|
||||
$ balena device rm 7cf02a6
|
||||
$ balena device rm 7cf02a6 --yes
|
||||
'''
|
||||
options: [ commandOptions.yes ]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
patterns.confirm(options.yes, 'Are you sure you want to delete the device?').then ->
|
||||
resin.models.device.remove(params.uuid)
|
||||
balena.models.device.remove(params.uuid)
|
||||
.nodeify(done)
|
||||
|
||||
exports.identify =
|
||||
@ -202,12 +205,13 @@ exports.identify =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device identify 23c73a1
|
||||
$ balena device identify 23c73a1
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.identify(params.uuid).nodeify(done)
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.identify(params.uuid).nodeify(done)
|
||||
|
||||
exports.reboot =
|
||||
signature: 'device reboot <uuid>'
|
||||
@ -217,13 +221,14 @@ exports.reboot =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device reboot 23c73a1
|
||||
$ balena device reboot 23c73a1
|
||||
'''
|
||||
options: [ commandOptions.forceUpdateLock ]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.reboot(params.uuid, options).nodeify(done)
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.reboot(params.uuid, options).nodeify(done)
|
||||
|
||||
exports.shutdown =
|
||||
signature: 'device shutdown <uuid>'
|
||||
@ -233,13 +238,14 @@ exports.shutdown =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device shutdown 23c73a1
|
||||
$ balena device shutdown 23c73a1
|
||||
'''
|
||||
options: [ commandOptions.forceUpdateLock ]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.shutdown(params.uuid, options).nodeify(done)
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.shutdown(params.uuid, options).nodeify(done)
|
||||
|
||||
exports.enableDeviceUrl =
|
||||
signature: 'device public-url enable <uuid>'
|
||||
@ -249,12 +255,13 @@ exports.enableDeviceUrl =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device public-url enable 23c73a1
|
||||
$ balena device public-url enable 23c73a1
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.enableDeviceUrl(params.uuid).nodeify(done)
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.enableDeviceUrl(params.uuid).nodeify(done)
|
||||
|
||||
exports.disableDeviceUrl =
|
||||
signature: 'device public-url disable <uuid>'
|
||||
@ -264,12 +271,13 @@ exports.disableDeviceUrl =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device public-url disable 23c73a1
|
||||
$ balena device public-url disable 23c73a1
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.disableDeviceUrl(params.uuid).nodeify(done)
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.disableDeviceUrl(params.uuid).nodeify(done)
|
||||
|
||||
exports.getDeviceUrl =
|
||||
signature: 'device public-url <uuid>'
|
||||
@ -279,12 +287,13 @@ exports.getDeviceUrl =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device public-url 23c73a1
|
||||
$ balena device public-url 23c73a1
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.getDeviceUrl(params.uuid).then (url) ->
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.getDeviceUrl(params.uuid).then (url) ->
|
||||
console.log(url)
|
||||
.nodeify(done)
|
||||
|
||||
@ -296,18 +305,19 @@ exports.hasDeviceUrl =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device public-url status 23c73a1
|
||||
$ balena device public-url status 23c73a1
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.device.hasDeviceUrl(params.uuid).then (hasDeviceUrl) ->
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.device.hasDeviceUrl(params.uuid).then (hasDeviceUrl) ->
|
||||
console.log(hasDeviceUrl)
|
||||
.nodeify(done)
|
||||
|
||||
exports.rename =
|
||||
signature: 'device rename <uuid> [newName]'
|
||||
description: 'rename a resin device'
|
||||
description: 'rename a balena device'
|
||||
help: '''
|
||||
Use this command to rename a device.
|
||||
|
||||
@ -315,13 +325,14 @@ exports.rename =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device rename 7cf02a6
|
||||
$ resin device rename 7cf02a6 MyPi
|
||||
$ balena device rename 7cf02a6
|
||||
$ balena device rename 7cf02a6 MyPi
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(params)
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
form = require('resin-cli-form')
|
||||
|
||||
Promise.try ->
|
||||
@ -331,7 +342,7 @@ exports.rename =
|
||||
message: 'How do you want to name this device?'
|
||||
type: 'input'
|
||||
|
||||
.then(_.partial(resin.models.device.rename, params.uuid))
|
||||
.then(_.partial(balena.models.device.rename, params.uuid))
|
||||
.nodeify(done)
|
||||
|
||||
exports.move =
|
||||
@ -344,30 +355,31 @@ exports.move =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device move 7cf02a6
|
||||
$ resin device move 7cf02a6 --application MyNewApp
|
||||
$ balena device move 7cf02a6
|
||||
$ balena device move 7cf02a6 --application MyNewApp
|
||||
'''
|
||||
permission: 'user'
|
||||
options: [ commandOptions.optionalApplication ]
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
normalizeUuidProp(params)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
resin.models.device.get(params.uuid).then (device) ->
|
||||
balena.models.device.get(params.uuid, expandForAppName).then (device) ->
|
||||
return options.application or patterns.selectApplication (application) ->
|
||||
return _.every [
|
||||
application.device_type is device.device_type
|
||||
device.application_name isnt application.app_name
|
||||
device.belongs_to__application[0].app_name isnt application.app_name
|
||||
]
|
||||
.tap (application) ->
|
||||
return resin.models.device.move(params.uuid, application)
|
||||
return balena.models.device.move(params.uuid, application)
|
||||
.then (application) ->
|
||||
console.info("#{params.uuid} was moved to #{application}")
|
||||
.nodeify(done)
|
||||
|
||||
exports.init =
|
||||
signature: 'device init'
|
||||
description: 'initialise a device with resinOS'
|
||||
description: 'initialise a device with balenaOS'
|
||||
help: '''
|
||||
Use this command to download the OS image of a certain application and write it to an SD Card.
|
||||
|
||||
@ -376,68 +388,67 @@ exports.init =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin device init
|
||||
$ resin device init --application MyApp
|
||||
$ balena device init
|
||||
$ balena device init --application MyApp
|
||||
'''
|
||||
options: [
|
||||
commandOptions.optionalApplication
|
||||
commandOptions.yes
|
||||
commandOptions.advancedConfig
|
||||
_.assign({}, commandOptions.osVersion, { signature: 'os-version', parameter: 'os-version' })
|
||||
_.assign({}, commandOptions.osVersionOrSemver, { signature: 'os-version', parameter: 'os-version' })
|
||||
commandOptions.drive
|
||||
{
|
||||
signature: 'config'
|
||||
description: 'path to the config JSON file, see `resin os build-config`'
|
||||
description: 'path to the config JSON file, see `balena os build-config`'
|
||||
parameter: 'config'
|
||||
}
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
||||
rimraf = Promise.promisify(require('rimraf'))
|
||||
tmp = require('tmp')
|
||||
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
||||
tmp.setGracefulCleanup()
|
||||
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
helpers = require('../utils/helpers')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
patterns = require('../utils/patterns')
|
||||
{ runCommand } = require('../utils/helpers')
|
||||
|
||||
Promise.try ->
|
||||
return options.application if options.application?
|
||||
return patterns.selectApplication()
|
||||
.then(resin.models.application.get)
|
||||
.then(balena.models.application.get)
|
||||
.then (application) ->
|
||||
|
||||
download = ->
|
||||
tmpNameAsync().then (tempPath) ->
|
||||
osVersion = options['os-version'] or 'default'
|
||||
capitanoRunAsync("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}")
|
||||
runCommand("os download #{application.device_type} --output '#{tempPath}' --version #{osVersion}")
|
||||
.disposer (tempPath) ->
|
||||
return rimraf(tempPath)
|
||||
|
||||
Promise.using download(), (tempPath) ->
|
||||
capitanoRunAsync("device register #{application.app_name}")
|
||||
.then(resin.models.device.get)
|
||||
runCommand("device register #{application.app_name}")
|
||||
.then(balena.models.device.get)
|
||||
.tap (device) ->
|
||||
configureCommand = "os configure '#{tempPath}' #{device.uuid}"
|
||||
configureCommand = "os configure '#{tempPath}' --device #{device.uuid}"
|
||||
if options.config
|
||||
configureCommand += " --config '#{options.config}'"
|
||||
else if options.advanced
|
||||
configureCommand += ' --advanced'
|
||||
capitanoRunAsync(configureCommand)
|
||||
runCommand(configureCommand)
|
||||
.then ->
|
||||
osInitCommand = "os initialize '#{tempPath}' --type #{application.device_type}"
|
||||
if options.yes
|
||||
osInitCommand += ' --yes'
|
||||
if options.drive
|
||||
osInitCommand += " --drive #{options.drive}"
|
||||
capitanoRunAsync(osInitCommand)
|
||||
runCommand(osInitCommand)
|
||||
# Make sure the device resource is removed if there is an
|
||||
# error when configuring or initializing a device image
|
||||
.catch (error) ->
|
||||
resin.models.device.remove(device.uuid).finally ->
|
||||
balena.models.device.remove(device.uuid).finally ->
|
||||
throw error
|
||||
.then (device) ->
|
||||
console.log('Done')
|
||||
|
@ -1,182 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
|
||||
exports.list =
|
||||
signature: 'envs'
|
||||
description: 'list all environment variables'
|
||||
help: '''
|
||||
Use this command to list all environment variables for
|
||||
a particular application or device.
|
||||
|
||||
This command lists all custom environment variables.
|
||||
If you want to see all environment variables, including private
|
||||
ones used by resin, use the verbose option.
|
||||
|
||||
Example:
|
||||
|
||||
$ resin envs --application MyApp
|
||||
$ resin envs --application MyApp --verbose
|
||||
$ resin envs --device 7cf02a6
|
||||
'''
|
||||
options: [
|
||||
commandOptions.optionalApplication
|
||||
commandOptions.optionalDevice
|
||||
|
||||
{
|
||||
signature: 'verbose'
|
||||
description: 'show private environment variables'
|
||||
boolean: true
|
||||
alias: 'v'
|
||||
}
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
Promise.try ->
|
||||
if options.application?
|
||||
return resin.models.environmentVariables.getAllByApplication(options.application)
|
||||
else if options.device?
|
||||
return resin.models.environmentVariables.device.getAll(options.device)
|
||||
else
|
||||
throw new Error('You must specify an application or device')
|
||||
|
||||
.tap (environmentVariables) ->
|
||||
if _.isEmpty(environmentVariables)
|
||||
throw new Error('No environment variables found')
|
||||
if not options.verbose
|
||||
isSystemVariable = resin.models.environmentVariables.isSystemVariable
|
||||
environmentVariables = _.reject(environmentVariables, isSystemVariable)
|
||||
|
||||
console.log visuals.table.horizontal environmentVariables, [
|
||||
'id'
|
||||
'name'
|
||||
'value'
|
||||
]
|
||||
.nodeify(done)
|
||||
|
||||
exports.remove =
|
||||
signature: 'env rm <id>'
|
||||
description: 'remove an environment variable'
|
||||
help: '''
|
||||
Use this command to remove an environment variable from an application.
|
||||
|
||||
Don't remove resin specific variables, as things might not work as expected.
|
||||
|
||||
Notice this command asks for confirmation interactively.
|
||||
You can avoid this by passing the `--yes` boolean option.
|
||||
|
||||
If you want to eliminate a device environment variable, pass the `--device` boolean option.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin env rm 215
|
||||
$ resin env rm 215 --yes
|
||||
$ resin env rm 215 --device
|
||||
'''
|
||||
options: [
|
||||
commandOptions.yes
|
||||
commandOptions.booleanDevice
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
patterns.confirm(options.yes, 'Are you sure you want to delete the environment variable?').then ->
|
||||
if options.device
|
||||
resin.models.environmentVariables.device.remove(params.id)
|
||||
else
|
||||
resin.models.environmentVariables.remove(params.id)
|
||||
.nodeify(done)
|
||||
|
||||
exports.add =
|
||||
signature: 'env add <key> [value]'
|
||||
description: 'add an environment variable'
|
||||
help: '''
|
||||
Use this command to add an enviroment variable to an application.
|
||||
|
||||
If value is omitted, the tool will attempt to use the variable's value
|
||||
as defined in your host machine.
|
||||
|
||||
Use the `--device` option if you want to assign the environment variable
|
||||
to a specific device.
|
||||
|
||||
If the value is grabbed from the environment, a warning message will be printed.
|
||||
Use `--quiet` to remove it.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin env add EDITOR vim --application MyApp
|
||||
$ resin env add TERM --application MyApp
|
||||
$ resin env add EDITOR vim --device 7cf02a6
|
||||
'''
|
||||
options: [
|
||||
commandOptions.optionalApplication
|
||||
commandOptions.optionalDevice
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
Promise.try ->
|
||||
if not params.value?
|
||||
params.value = process.env[params.key]
|
||||
|
||||
if not params.value?
|
||||
throw new Error("Environment value not found for key: #{params.key}")
|
||||
else
|
||||
console.info("Warning: using #{params.key}=#{params.value} from host environment")
|
||||
|
||||
if options.application?
|
||||
resin.models.environmentVariables.create(options.application, params.key, params.value)
|
||||
else if options.device?
|
||||
resin.models.environmentVariables.device.create(options.device, params.key, params.value)
|
||||
else
|
||||
throw new Error('You must specify an application or device')
|
||||
.nodeify(done)
|
||||
|
||||
exports.rename =
|
||||
signature: 'env rename <id> <value>'
|
||||
description: 'rename an environment variable'
|
||||
help: '''
|
||||
Use this command to rename an enviroment variable from an application.
|
||||
|
||||
Pass the `--device` boolean option if you want to rename a device environment variable.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin env rename 376 emacs
|
||||
$ resin env rename 376 emacs --device
|
||||
'''
|
||||
permission: 'user'
|
||||
options: [ commandOptions.booleanDevice ]
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
Promise.try ->
|
||||
if options.device
|
||||
resin.models.environmentVariables.device.update(params.id, params.value)
|
||||
else
|
||||
resin.models.environmentVariables.update(params.id, params.value)
|
||||
.nodeify(done)
|
306
lib/actions/environment-variables.ts
Normal file
306
lib/actions/environment-variables.ts
Normal file
@ -0,0 +1,306 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { ApplicationVariable, DeviceVariable } from 'balena-sdk';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
import { normalizeUuidProp } from '../utils/normalization';
|
||||
import * as commandOptions from './command-options';
|
||||
|
||||
const getReservedPrefixes = async (): Promise<string[]> => {
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const settings = await balena.settings.getAll();
|
||||
|
||||
const response = await balena.request.send({
|
||||
baseUrl: settings.apiUrl,
|
||||
url: '/config/vars',
|
||||
});
|
||||
|
||||
return response.body.reservedNamespaces;
|
||||
};
|
||||
|
||||
export const list: CommandDefinition<
|
||||
{},
|
||||
{
|
||||
application?: string;
|
||||
device?: string;
|
||||
config: boolean;
|
||||
}
|
||||
> = {
|
||||
signature: 'envs',
|
||||
description: 'list all environment variables',
|
||||
help: stripIndent`
|
||||
Use this command to list the environment variables of an application
|
||||
or device.
|
||||
|
||||
The --config option is used to list "config" variables that configure
|
||||
balena features.
|
||||
|
||||
Service-specific variables are not currently supported. The following
|
||||
examples list variables that apply to all services in an app or device.
|
||||
|
||||
Example:
|
||||
|
||||
$ balena envs --application MyApp
|
||||
$ balena envs --application MyApp --config
|
||||
$ balena envs --device 7cf02a6
|
||||
`,
|
||||
options: [
|
||||
commandOptions.optionalApplication,
|
||||
commandOptions.optionalDevice,
|
||||
|
||||
{
|
||||
signature: 'config',
|
||||
description: 'show config variables',
|
||||
boolean: true,
|
||||
alias: ['c', 'v', 'verbose'],
|
||||
},
|
||||
],
|
||||
permission: 'user',
|
||||
async action(_params, options, done) {
|
||||
normalizeUuidProp(options, 'device');
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const visuals = await import('resin-cli-visuals');
|
||||
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
return Bluebird.try(function(): Promise<
|
||||
DeviceVariable[] | ApplicationVariable[]
|
||||
> {
|
||||
if (options.application) {
|
||||
return balena.models.application[
|
||||
options.config ? 'configVar' : 'envVar'
|
||||
].getAllByApplication(options.application);
|
||||
} else if (options.device) {
|
||||
return balena.models.device[
|
||||
options.config ? 'configVar' : 'envVar'
|
||||
].getAllByDevice(options.device);
|
||||
} else {
|
||||
return exitWithExpectedError(
|
||||
'You must specify an application or device',
|
||||
);
|
||||
}
|
||||
})
|
||||
.tap(function(environmentVariables) {
|
||||
if (_.isEmpty(environmentVariables)) {
|
||||
exitWithExpectedError('No environment variables found');
|
||||
}
|
||||
|
||||
console.log(
|
||||
visuals.table.horizontal(environmentVariables, [
|
||||
'id',
|
||||
'name',
|
||||
'value',
|
||||
]),
|
||||
);
|
||||
})
|
||||
.nodeify(done);
|
||||
},
|
||||
};
|
||||
|
||||
export const remove: CommandDefinition<
|
||||
{
|
||||
id: number;
|
||||
},
|
||||
{
|
||||
yes: boolean;
|
||||
device: boolean;
|
||||
}
|
||||
> = {
|
||||
signature: 'env rm <id>',
|
||||
description: 'remove an environment variable',
|
||||
help: stripIndent`
|
||||
Use this command to remove an environment variable from an application
|
||||
or device.
|
||||
|
||||
Notice this command asks for confirmation interactively.
|
||||
You can avoid this by passing the \`--yes\` boolean option.
|
||||
|
||||
The --device option selects a device instead of an application.
|
||||
|
||||
Service-specific variables are not currently supported. The following
|
||||
examples remove variables that apply to all services in an app or device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena env rm 215
|
||||
$ balena env rm 215 --yes
|
||||
$ balena env rm 215 --device
|
||||
`,
|
||||
options: [commandOptions.yes, commandOptions.booleanDevice],
|
||||
permission: 'user',
|
||||
async action(params, options, done) {
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const patterns = await import('../utils/patterns');
|
||||
|
||||
return patterns
|
||||
.confirm(
|
||||
options.yes,
|
||||
'Are you sure you want to delete the environment variable?',
|
||||
)
|
||||
.then(function() {
|
||||
if (options.device) {
|
||||
return balena.pine.delete({
|
||||
resource: 'device_environment_variable',
|
||||
id: params.id,
|
||||
});
|
||||
} else {
|
||||
return balena.pine.delete({
|
||||
resource: 'application_environment_variable',
|
||||
id: params.id,
|
||||
});
|
||||
}
|
||||
})
|
||||
.nodeify(done);
|
||||
},
|
||||
};
|
||||
|
||||
export const add: CommandDefinition<
|
||||
{
|
||||
key: string;
|
||||
value?: string;
|
||||
},
|
||||
{
|
||||
application?: string;
|
||||
device?: string;
|
||||
}
|
||||
> = {
|
||||
signature: 'env add <key> [value]',
|
||||
description: 'add an environment or config variable',
|
||||
help: stripIndent`
|
||||
Use this command to add an enviroment or config variable to an application
|
||||
or device.
|
||||
|
||||
If value is omitted, the tool will attempt to use the variable's value
|
||||
as defined in your host machine.
|
||||
|
||||
Use the \`--device\` option if you want to assign the environment variable
|
||||
to a specific device.
|
||||
|
||||
If the value is grabbed from the environment, a warning message will be printed.
|
||||
Use \`--quiet\` to remove it.
|
||||
|
||||
Service-specific variables are not currently supported. The following
|
||||
examples set variables that apply to all services in an app or device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena env add EDITOR vim --application MyApp
|
||||
$ balena env add TERM --application MyApp
|
||||
$ balena env add EDITOR vim --device 7cf02a6
|
||||
`,
|
||||
options: [commandOptions.optionalApplication, commandOptions.optionalDevice],
|
||||
permission: 'user',
|
||||
async action(params, options, done) {
|
||||
normalizeUuidProp(options, 'device');
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
return Bluebird.try(async function() {
|
||||
if (params.value == null) {
|
||||
params.value = process.env[params.key];
|
||||
|
||||
if (params.value == null) {
|
||||
throw new Error(`Environment value not found for key: ${params.key}`);
|
||||
} else {
|
||||
console.info(
|
||||
`Warning: using ${params.key}=${
|
||||
params.value
|
||||
} from host environment`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reservedPrefixes = await getReservedPrefixes();
|
||||
const isConfigVar = _.some(reservedPrefixes, prefix =>
|
||||
_.startsWith(params.key, prefix),
|
||||
);
|
||||
|
||||
if (options.application) {
|
||||
return balena.models.application[
|
||||
isConfigVar ? 'configVar' : 'envVar'
|
||||
].set(options.application, params.key, params.value);
|
||||
} else if (options.device) {
|
||||
return balena.models.device[isConfigVar ? 'configVar' : 'envVar'].set(
|
||||
options.device,
|
||||
params.key,
|
||||
params.value,
|
||||
);
|
||||
} else {
|
||||
exitWithExpectedError('You must specify an application or device');
|
||||
}
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
||||
|
||||
export const rename: CommandDefinition<
|
||||
{
|
||||
id: number;
|
||||
value: string;
|
||||
},
|
||||
{
|
||||
device: boolean;
|
||||
}
|
||||
> = {
|
||||
signature: 'env rename <id> <value>',
|
||||
description: 'rename an environment variable',
|
||||
help: stripIndent`
|
||||
Use this command to change the value of an application or device
|
||||
enviroment variable.
|
||||
|
||||
The --device option selects a device instead of an application.
|
||||
|
||||
Service-specific variables are not currently supported. The following
|
||||
examples modify variables that apply to all services in an app or device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena env rename 376 emacs
|
||||
$ balena env rename 376 emacs --device
|
||||
`,
|
||||
permission: 'user',
|
||||
options: [commandOptions.booleanDevice],
|
||||
async action(params, options, done) {
|
||||
const Bluebird = await import('bluebird');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
|
||||
return Bluebird.try(function() {
|
||||
if (options.device) {
|
||||
return balena.pine.patch({
|
||||
resource: 'device_environment_variable',
|
||||
id: params.id,
|
||||
body: {
|
||||
value: params.value,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return balena.pine.patch({
|
||||
resource: 'application_environment_variable',
|
||||
id: params.id,
|
||||
body: {
|
||||
value: params.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -18,9 +18,10 @@ _ = require('lodash')
|
||||
capitano = require('capitano')
|
||||
columnify = require('columnify')
|
||||
messages = require('../utils/messages')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
parse = (object) ->
|
||||
return _.fromPairs _.map object, (item) ->
|
||||
return _.fromPairs _.map(object, (item) ->
|
||||
|
||||
# Hacky way to determine if an object is
|
||||
# a function or a command
|
||||
@ -32,7 +33,7 @@ parse = (object) ->
|
||||
return [
|
||||
signature
|
||||
item.description
|
||||
]
|
||||
]).sort()
|
||||
|
||||
indent = (text) ->
|
||||
text = _.map text.split('\n'), (line) ->
|
||||
@ -45,7 +46,7 @@ print = (data) ->
|
||||
minWidth: 35
|
||||
|
||||
general = (params, options, done) ->
|
||||
console.log('Usage: resin [COMMAND] [OPTIONS]\n')
|
||||
console.log('Usage: balena [COMMAND] [OPTIONS]\n')
|
||||
console.log(messages.reachingOut)
|
||||
console.log('\nPrimary commands:\n')
|
||||
|
||||
@ -55,8 +56,6 @@ general = (params, options, done) ->
|
||||
return command.hidden or command.isWildcard()
|
||||
|
||||
groupedCommands = _.groupBy commands, (command) ->
|
||||
if command.plugin
|
||||
return 'plugins'
|
||||
if command.primary
|
||||
return 'primary'
|
||||
return 'secondary'
|
||||
@ -64,14 +63,10 @@ general = (params, options, done) ->
|
||||
print(parse(groupedCommands.primary))
|
||||
|
||||
if options.verbose
|
||||
if not _.isEmpty(groupedCommands.plugins)
|
||||
console.log('\nInstalled plugins:\n')
|
||||
print(parse(groupedCommands.plugins))
|
||||
|
||||
console.log('\nAdditional commands:\n')
|
||||
print(parse(groupedCommands.secondary))
|
||||
else
|
||||
console.log('\nRun `resin help --verbose` to list additional commands')
|
||||
console.log('\nRun `balena help --verbose` to list additional commands')
|
||||
|
||||
if not _.isEmpty(capitano.state.globalOptions)
|
||||
console.log('\nGlobal Options:\n')
|
||||
@ -84,7 +79,7 @@ command = (params, options, done) ->
|
||||
return done(error) if error?
|
||||
|
||||
if not command? or command.isWildcard()
|
||||
return done(new Error("Command not found: #{params.command}"))
|
||||
exitWithExpectedError("Command not found: #{params.command}")
|
||||
|
||||
console.log("Usage: #{command.signature}")
|
||||
|
||||
@ -107,8 +102,8 @@ exports.help =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin help apps
|
||||
$ resin help os download
|
||||
$ balena help apps
|
||||
$ balena help os download
|
||||
'''
|
||||
primary: true
|
||||
options: [
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -15,12 +15,13 @@ limitations under the License.
|
||||
###
|
||||
|
||||
module.exports =
|
||||
wizard: require('./wizard')
|
||||
apiKey: require('./api-key')
|
||||
app: require('./app')
|
||||
info: require('./info')
|
||||
auth: require('./auth')
|
||||
info: require('./info')
|
||||
device: require('./device')
|
||||
env: require('./environment-variables')
|
||||
tags: require('./tags')
|
||||
keys: require('./keys')
|
||||
logs: require('./logs')
|
||||
local: require('./local')
|
||||
@ -36,3 +37,7 @@ module.exports =
|
||||
deploy: require('./deploy')
|
||||
util: require('./util')
|
||||
preload: require('./preload')
|
||||
push: require('./push')
|
||||
join: require('./join')
|
||||
leave: require('./leave')
|
||||
tunnel: require('./tunnel')
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
/*
|
||||
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.
|
||||
@ -12,15 +12,19 @@ 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.
|
||||
###
|
||||
*/
|
||||
|
||||
exports.version =
|
||||
signature: 'version'
|
||||
description: 'output the version number'
|
||||
help: '''
|
||||
Display the Resin CLI version.
|
||||
'''
|
||||
action: (params, options, done) ->
|
||||
packageJSON = require('../../package.json')
|
||||
console.log(packageJSON.version)
|
||||
return done()
|
||||
import { CommandDefinition } from 'capitano';
|
||||
|
||||
export const version: CommandDefinition = {
|
||||
signature: 'version',
|
||||
description: 'output the version number',
|
||||
help: `\
|
||||
Display the balena CLI version.\
|
||||
`,
|
||||
async action(_params, _options, done) {
|
||||
const packageJSON = await import('../../package.json');
|
||||
console.log(packageJSON.version);
|
||||
return done();
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -21,17 +21,68 @@ exports.osInit =
|
||||
signature: 'internal osinit <image> <type> <config>'
|
||||
description: 'do actual init of the device with the preconfigured os image'
|
||||
help: '''
|
||||
Don't use this command directly! Use `resin os initialize <image>` instead.
|
||||
Don't use this command directly! Use `balena os initialize <image>` instead.
|
||||
'''
|
||||
hidden: true
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
init = require('resin-device-init')
|
||||
init = require('balena-device-init')
|
||||
helpers = require('../utils/helpers')
|
||||
|
||||
return Promise.try ->
|
||||
config = JSON.parse(params.config)
|
||||
init.initialize(params.image, params.type, config)
|
||||
configPromise = Promise.try -> JSON.parse(params.config)
|
||||
manifestPromise = helpers.getManifest(params.image, params.type)
|
||||
Promise.join configPromise, manifestPromise, (config, manifest) ->
|
||||
init.initialize(params.image, manifest, config)
|
||||
.then(helpers.osProgressHandler)
|
||||
.nodeify(done)
|
||||
|
||||
exports.scanDevices =
|
||||
signature: 'internal scandevices'
|
||||
description: 'scan for local balena-enabled devices and show a picker to choose one'
|
||||
help: '''
|
||||
Don't use this command directly!
|
||||
'''
|
||||
hidden: true
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
{ forms } = require('balena-sync')
|
||||
|
||||
return Promise.try ->
|
||||
forms.selectLocalBalenaOsDevice()
|
||||
.then (hostnameOrIp) ->
|
||||
console.error("==> Selected device: #{hostnameOrIp}")
|
||||
.nodeify(done)
|
||||
|
||||
exports.sudo =
|
||||
signature: 'internal sudo <command>'
|
||||
description: 'execute arbitrary commands in a privileged subprocess'
|
||||
help: '''
|
||||
Don't use this command directly!
|
||||
|
||||
<command> must be passed as a single argument. That means, you need to make sure
|
||||
you enclose <command> in quotes (eg. balena internal sudo 'ls -alF') if for
|
||||
whatever reason you invoke the command directly or, typically, pass <command>
|
||||
as a single argument to spawn (eg. `spawn('balena', [ 'internal', 'sudo', 'ls -alF' ])`).
|
||||
|
||||
Furthermore, this command will naively split <command> on whitespace and directly
|
||||
forward the parts as arguments to `sudo`, so be careful.
|
||||
'''
|
||||
hidden: true
|
||||
action: (params, options, done) ->
|
||||
os = require('os')
|
||||
Promise = require('bluebird')
|
||||
|
||||
return Promise.try ->
|
||||
if os.platform() is 'win32'
|
||||
windosu = require('windosu')
|
||||
windosu.exec(params.command, {})
|
||||
else
|
||||
{ spawn } = require('child_process')
|
||||
{ wait } = require('rindle')
|
||||
cmd = params.command.split(' ')
|
||||
ps = spawn('sudo', cmd, stdio: 'inherit', env: process.env)
|
||||
wait(ps)
|
||||
|
||||
.nodeify(done)
|
||||
|
74
lib/actions/join.ts
Normal file
74
lib/actions/join.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
import * as Bluebird from 'bluebird';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
interface Args {
|
||||
deviceIp?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
application?: string;
|
||||
}
|
||||
|
||||
export const join: CommandDefinition<Args, Options> = {
|
||||
signature: 'join [deviceIp]',
|
||||
description:
|
||||
'Promote a local device running balenaOS to join an application on a balena server',
|
||||
help: stripIndent`
|
||||
Use this command to move a local device to an application on another balena server.
|
||||
|
||||
For example, you could provision a device against an openBalena installation
|
||||
where you perform end-to-end tests and then move it to balenaCloud when it's
|
||||
ready for production.
|
||||
|
||||
Moving a device between applications on the same server is not supported.
|
||||
|
||||
If you don't specify a device hostname or IP, this command will automatically
|
||||
scan the local network for balenaOS devices and prompt you to select one
|
||||
from an interactive picker. This usually requires root privileges.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena join
|
||||
$ balena join balena.local
|
||||
$ balena join balena.local --application MyApp
|
||||
$ balena join 192.168.1.25
|
||||
$ balena join 192.168.1.25 --application MyApp
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
signature: 'application',
|
||||
parameter: 'application',
|
||||
alias: 'a',
|
||||
description: 'The name of the application the device should join',
|
||||
},
|
||||
],
|
||||
|
||||
primary: true,
|
||||
|
||||
async action(params, options, done) {
|
||||
const balena = await import('balena-sdk');
|
||||
const Logger = await import('../utils/logger');
|
||||
const promote = await import('../utils/promote');
|
||||
const sdk = balena.fromSharedOptions();
|
||||
const logger = new Logger();
|
||||
return Bluebird.try(() => {
|
||||
return promote.join(logger, sdk, params.deviceIp, options.application);
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -24,14 +24,14 @@ exports.list =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin keys
|
||||
$ balena keys
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.key.getAll().then (keys) ->
|
||||
balena.models.key.getAll().then (keys) ->
|
||||
console.log visuals.table.horizontal keys, [
|
||||
'id'
|
||||
'title'
|
||||
@ -46,14 +46,14 @@ exports.info =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin key 17
|
||||
$ balena key 17
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
resin.models.key.get(params.id).then (key) ->
|
||||
balena.models.key.get(params.id).then (key) ->
|
||||
console.log visuals.table.vertical key, [
|
||||
'id'
|
||||
'title'
|
||||
@ -61,7 +61,7 @@ exports.info =
|
||||
|
||||
# Since the public key string is long, it might
|
||||
# wrap to lines below, causing the table layout to break.
|
||||
# See https://github.com/resin-io/resin-cli/issues/151
|
||||
# See https://github.com/balena-io/balena-cli/issues/151
|
||||
console.log('\n' + key.public_key)
|
||||
.nodeify(done)
|
||||
|
||||
@ -69,29 +69,29 @@ exports.remove =
|
||||
signature: 'key rm <id>'
|
||||
description: 'remove a ssh key'
|
||||
help: '''
|
||||
Use this command to remove a SSH key from resin.io.
|
||||
Use this command to remove a SSH key from balena.
|
||||
|
||||
Notice this command asks for confirmation interactively.
|
||||
You can avoid this by passing the `--yes` boolean option.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin key rm 17
|
||||
$ resin key rm 17 --yes
|
||||
$ balena key rm 17
|
||||
$ balena key rm 17 --yes
|
||||
'''
|
||||
options: [ commandOptions.yes ]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
patterns.confirm(options.yes, 'Are you sure you want to delete the key?').then ->
|
||||
resin.models.key.remove(params.id)
|
||||
balena.models.key.remove(params.id)
|
||||
.nodeify(done)
|
||||
|
||||
exports.add =
|
||||
signature: 'key add <name> [path]'
|
||||
description: 'add a SSH key to resin.io'
|
||||
description: 'add a SSH key to balena'
|
||||
help: '''
|
||||
Use this command to associate a new SSH key with your account.
|
||||
|
||||
@ -100,8 +100,8 @@ exports.add =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin key add Main ~/.ssh/id_rsa.pub
|
||||
$ cat ~/.ssh/id_rsa.pub | resin key add Main
|
||||
$ balena key add Main ~/.ssh/id_rsa.pub
|
||||
$ cat ~/.ssh/id_rsa.pub | balena key add Main
|
||||
'''
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
@ -109,7 +109,7 @@ exports.add =
|
||||
Promise = require('bluebird')
|
||||
readFileAsync = Promise.promisify(require('fs').readFile)
|
||||
capitano = require('capitano')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
Promise.try ->
|
||||
return readFileAsync(params.path, encoding: 'utf8') if params.path?
|
||||
@ -119,5 +119,5 @@ exports.add =
|
||||
capitano.utils.getStdin (data) ->
|
||||
return callback(null, data)
|
||||
|
||||
.then(_.partial(resin.models.key.create, params.name))
|
||||
.then(_.partial(balena.models.key.create, params.name))
|
||||
.nodeify(done)
|
||||
|
59
lib/actions/leave.ts
Normal file
59
lib/actions/leave.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
import * as Bluebird from 'bluebird';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
interface Args {
|
||||
deviceIp?: string;
|
||||
}
|
||||
|
||||
export const leave: CommandDefinition<Args, {}> = {
|
||||
signature: 'leave [deviceIp]',
|
||||
description: 'Detach a local device from its balena application',
|
||||
help: stripIndent`
|
||||
Use this command to make a local device leave the balena server it is
|
||||
provisioned on. This effectively makes the device "unmanaged".
|
||||
|
||||
The device entry on the server is preserved after running this command,
|
||||
so the device can subsequently re-join the server if needed.
|
||||
|
||||
If you don't specify a device hostname or IP, this command will automatically
|
||||
scan the local network for balenaOS devices and prompt you to select one
|
||||
from an interactive picker. This usually requires root privileges.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena leave
|
||||
$ balena leave balena.local
|
||||
$ balena leave 192.168.1.25
|
||||
`,
|
||||
options: [],
|
||||
|
||||
permission: 'user',
|
||||
primary: true,
|
||||
|
||||
async action(params, _options, done) {
|
||||
const balena = await import('balena-sdk');
|
||||
const Logger = await import('../utils/logger');
|
||||
const promote = await import('../utils/promote');
|
||||
const sdk = balena.fromSharedOptions();
|
||||
const logger = new Logger();
|
||||
return Bluebird.try(() => {
|
||||
return promote.leave(logger, sdk, params.deviceIp);
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
@ -1,19 +1,21 @@
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
Docker = require('docker-toolbelt')
|
||||
form = require('resin-cli-form')
|
||||
chalk = require('chalk')
|
||||
|
||||
dockerUtils = require('../../utils/docker')
|
||||
{ exitWithExpectedError } = require('../../utils/patterns')
|
||||
|
||||
exports.dockerPort = dockerPort = 2375
|
||||
exports.dockerTimeout = dockerTimeout = 2000
|
||||
|
||||
exports.filterOutSupervisorContainer = filterOutSupervisorContainer = (container) ->
|
||||
for name in container.Names
|
||||
return false if name.includes('resin_supervisor')
|
||||
return false if (name.includes('resin_supervisor') or name.includes('balena_supervisor'))
|
||||
return true
|
||||
|
||||
exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor = false) ->
|
||||
docker = new Docker(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
|
||||
form = require('resin-cli-form')
|
||||
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
|
||||
|
||||
# List all containers, including those not running
|
||||
docker.listContainersAsync(all: true)
|
||||
@ -22,23 +24,22 @@ exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor =
|
||||
filterOutSupervisorContainer(container)
|
||||
.then (containers) ->
|
||||
if _.isEmpty(containers)
|
||||
throw new Error("No containers found in #{deviceIp}")
|
||||
exitWithExpectedError("No containers found in #{deviceIp}")
|
||||
|
||||
return form.ask
|
||||
message: 'Select a container'
|
||||
type: 'list'
|
||||
choices: _.map containers, (container) ->
|
||||
containerName = container.Names[0] or 'Untitled'
|
||||
containerName = container.Names?[0] or 'Untitled'
|
||||
shortContainerId = ('' + container.Id).substr(0, 11)
|
||||
containerStatus = container.Status
|
||||
|
||||
return {
|
||||
name: "#{containerName} (#{shortContainerId}) - #{containerStatus}"
|
||||
name: "#{containerName} (#{shortContainerId})"
|
||||
value: container.Id
|
||||
}
|
||||
|
||||
exports.pipeContainerStream = Promise.method ({ deviceIp, name, outStream, follow = false }) ->
|
||||
docker = new Docker(host: deviceIp, port: dockerPort)
|
||||
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort)
|
||||
|
||||
container = docker.getContainer(name)
|
||||
container.inspectAsync()
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
BOOT_PARTITION = { primary: 1 }
|
||||
BOOT_PARTITION = 1
|
||||
CONNECTIONS_FOLDER = '/system-connections'
|
||||
|
||||
getConfigurationSchema = (connnectionFileName = 'resin-wifi') ->
|
||||
@ -51,6 +51,8 @@ getConfigurationSchema = (connnectionFileName = 'resin-wifi') ->
|
||||
type: 'ini'
|
||||
location:
|
||||
path: CONNECTIONS_FOLDER.slice(1)
|
||||
# Reconfix still uses the older resin-image-fs, so still needs an
|
||||
# object-based partition definition.
|
||||
partition: BOOT_PARTITION
|
||||
config_json:
|
||||
type: 'json'
|
||||
@ -151,7 +153,7 @@ prepareConnectionFile = (target) ->
|
||||
if _.includes(files, 'resin-wifi')
|
||||
return null
|
||||
|
||||
# Fresh image, new mode, accoding to https://github.com/resin-os/meta-resin/pull/770/files
|
||||
# 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
|
||||
@ -191,14 +193,14 @@ removeHostname = (schema) ->
|
||||
|
||||
module.exports =
|
||||
signature: 'local configure <target>'
|
||||
description: '(Re)configure a resinOS drive or image'
|
||||
description: '(Re)configure a balenaOS drive or image'
|
||||
help: '''
|
||||
Use this command to configure or reconfigure a resinOS drive or image.
|
||||
Use this command to configure or reconfigure a balenaOS drive or image.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local configure /dev/sdc
|
||||
$ resin local configure path/to/image.img
|
||||
$ balena local configure /dev/sdc
|
||||
$ balena local configure path/to/image.img
|
||||
'''
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
|
@ -1,120 +0,0 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
module.exports =
|
||||
signature: 'local flash <image>'
|
||||
description: 'Flash an image to a drive'
|
||||
help: '''
|
||||
Use this command to flash a resinOS image to a drive.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local flash path/to/resinos.img
|
||||
$ resin local flash path/to/resinos.img --drive /dev/disk2
|
||||
$ resin local flash path/to/resinos.img --drive /dev/disk2 --yes
|
||||
'''
|
||||
options: [
|
||||
signature: 'yes'
|
||||
boolean: true
|
||||
description: 'confirm non-interactively'
|
||||
alias: 'y'
|
||||
,
|
||||
signature: 'drive'
|
||||
parameter: 'drive'
|
||||
description: 'drive'
|
||||
alias: 'd'
|
||||
]
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
|
||||
_ = require('lodash')
|
||||
os = require('os')
|
||||
Promise = require('bluebird')
|
||||
umountAsync = Promise.promisify(require('umount').umount)
|
||||
fs = Promise.promisifyAll(require('fs'))
|
||||
driveListAsync = Promise.promisify(require('drivelist').list)
|
||||
chalk = require('chalk')
|
||||
visuals = require('resin-cli-visuals')
|
||||
form = require('resin-cli-form')
|
||||
imageWrite = require('etcher-image-write')
|
||||
|
||||
form.run [
|
||||
{
|
||||
message: 'Select drive'
|
||||
type: 'drive'
|
||||
name: 'drive'
|
||||
},
|
||||
{
|
||||
message: 'This will erase the selected drive. Are you sure?'
|
||||
type: 'confirm'
|
||||
name: 'yes'
|
||||
default: false
|
||||
}
|
||||
],
|
||||
override:
|
||||
drive: options.drive
|
||||
|
||||
# If `options.yes` is `false`, pass `undefined`,
|
||||
# otherwise the question will not be asked because
|
||||
# `false` is a defined value.
|
||||
yes: options.yes || undefined
|
||||
|
||||
# TODO: dedupe with the resin-device-operations
|
||||
.then (answers) ->
|
||||
if answers.yes isnt true
|
||||
console.log(chalk.red.bold('Aborted image flash'))
|
||||
process.exit(0)
|
||||
|
||||
driveListAsync().then (drives) ->
|
||||
selectedDrive = _.find(drives, device: answers.drive)
|
||||
|
||||
if not selectedDrive?
|
||||
throw new Error("Drive not found: #{answers.drive}")
|
||||
|
||||
return selectedDrive
|
||||
.then (selectedDrive) ->
|
||||
progressBars =
|
||||
write: new visuals.Progress('Flashing')
|
||||
check: new visuals.Progress('Validating')
|
||||
|
||||
umountAsync(selectedDrive.device).then ->
|
||||
Promise.props
|
||||
imageSize: fs.statAsync(params.image).get('size'),
|
||||
imageStream: Promise.resolve(fs.createReadStream(params.image))
|
||||
driveFileDescriptor: fs.openAsync(selectedDrive.raw, 'rs+')
|
||||
.then (results) ->
|
||||
imageWrite.write
|
||||
fd: results.driveFileDescriptor
|
||||
device: selectedDrive.raw
|
||||
size: selectedDrive.size
|
||||
,
|
||||
stream: results.imageStream,
|
||||
size: results.imageSize
|
||||
,
|
||||
check: true
|
||||
.then (writer) ->
|
||||
new Promise (resolve, reject) ->
|
||||
writer.on 'progress', (state) ->
|
||||
progressBars[state.type].update(state)
|
||||
writer.on('error', reject)
|
||||
writer.on('done', resolve)
|
||||
.then ->
|
||||
if (os.platform() is 'win32') and selectedDrive.mountpoint?
|
||||
ejectAsync = Promise.promisify(require('removedrive').eject)
|
||||
return ejectAsync(selectedDrive.mountpoint)
|
||||
|
||||
return umountAsync(selectedDrive.device)
|
||||
.asCallback(done)
|
120
lib/actions/local/flash.ts
Normal file
120
lib/actions/local/flash.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
*/
|
||||
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import chalk from 'chalk';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import * as SDK from 'etcher-sdk';
|
||||
|
||||
async function getDrive(options: {
|
||||
drive?: string;
|
||||
}): Promise<SDK.sourceDestination.BlockDevice> {
|
||||
const sdk = await import('etcher-sdk');
|
||||
|
||||
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
|
||||
const scanner = new sdk.scanner.Scanner([adapter]);
|
||||
await scanner.start();
|
||||
let drive: SDK.sourceDestination.BlockDevice;
|
||||
if (options.drive !== undefined) {
|
||||
const d = scanner.getBy('device', options.drive);
|
||||
if (d === undefined || !(d instanceof sdk.sourceDestination.BlockDevice)) {
|
||||
throw new Error(`Drive not found: ${options.drive}`);
|
||||
}
|
||||
drive = d;
|
||||
} else {
|
||||
const { DriveList } = await import('../../utils/visuals/drive-list');
|
||||
const driveList = new DriveList(scanner);
|
||||
drive = await driveList.run();
|
||||
}
|
||||
scanner.stop();
|
||||
return drive;
|
||||
}
|
||||
|
||||
export const flash: CommandDefinition<
|
||||
{ image: string },
|
||||
{ drive: string; yes: boolean }
|
||||
> = {
|
||||
signature: 'local flash <image>',
|
||||
description: 'Flash an image to a drive',
|
||||
help: stripIndent`
|
||||
Use this command to flash a balenaOS image to a drive.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena local flash path/to/balenaos.img[.zip|.gz|.bz2|.xz]
|
||||
$ balena local flash path/to/balenaos.img --drive /dev/disk2
|
||||
$ balena local flash path/to/balenaos.img --drive /dev/disk2 --yes
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
signature: 'yes',
|
||||
boolean: true,
|
||||
description: 'confirm non-interactively',
|
||||
alias: 'y',
|
||||
},
|
||||
{
|
||||
signature: 'drive',
|
||||
parameter: 'drive',
|
||||
description: 'drive',
|
||||
alias: 'd',
|
||||
},
|
||||
],
|
||||
async action(params, options) {
|
||||
const visuals = await import('resin-cli-visuals');
|
||||
const form = await import('resin-cli-form');
|
||||
const { sourceDestination, multiWrite } = await import('etcher-sdk');
|
||||
|
||||
const drive = await getDrive(options);
|
||||
|
||||
const yes =
|
||||
options.yes ||
|
||||
(await form.ask({
|
||||
message: 'This will erase the selected drive. Are you sure?',
|
||||
type: 'confirm',
|
||||
name: 'yes',
|
||||
default: false,
|
||||
}));
|
||||
if (yes !== true) {
|
||||
console.log(chalk.red.bold('Aborted image flash'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const file = new sourceDestination.File(
|
||||
params.image,
|
||||
sourceDestination.File.OpenFlags.Read,
|
||||
);
|
||||
const source = await file.getInnerSource();
|
||||
|
||||
const progressBars: { [key: string]: any } = {
|
||||
flashing: new visuals.Progress('Flashing'),
|
||||
verifying: new visuals.Progress('Validating'),
|
||||
};
|
||||
|
||||
await multiWrite.pipeSourceToDestinations(
|
||||
source,
|
||||
[drive],
|
||||
(_, error) => {
|
||||
// onFail
|
||||
console.log(chalk.red.bold(error.message));
|
||||
},
|
||||
(progress: SDK.multiWrite.MultiDestinationProgress) => {
|
||||
// onProgress
|
||||
progressBars[progress.type].update(progress);
|
||||
},
|
||||
true, // verify
|
||||
);
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -15,7 +15,7 @@ limitations under the License.
|
||||
###
|
||||
|
||||
exports.configure = require('./configure')
|
||||
exports.flash = require('./flash')
|
||||
exports.flash = require('./flash').flash
|
||||
exports.logs = require('./logs')
|
||||
exports.scan = require('./scan')
|
||||
exports.ssh = require('./ssh')
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -22,16 +22,16 @@ limitations under the License.
|
||||
#
|
||||
module.exports =
|
||||
signature: 'local logs [deviceIp]'
|
||||
description: 'Get or attach to logs of a running container on a resinOS device'
|
||||
description: 'Get or attach to logs of a running container on a balenaOS device'
|
||||
help: '''
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local logs
|
||||
$ resin local logs -f
|
||||
$ resin local logs 192.168.1.10
|
||||
$ resin local logs 192.168.1.10 -f
|
||||
$ resin local logs 192.168.1.10 -f --app-name myapp
|
||||
$ balena local logs
|
||||
$ balena local logs -f
|
||||
$ balena local logs 192.168.1.10
|
||||
$ balena local logs 192.168.1.10 -f
|
||||
$ balena local logs 192.168.1.10 -f --app-name myapp
|
||||
'''
|
||||
options: [
|
||||
signature: 'follow'
|
||||
@ -47,12 +47,12 @@ module.exports =
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
{ forms } = require('resin-sync')
|
||||
{ forms } = require('balena-sync')
|
||||
{ selectContainerFromDevice, pipeContainerStream } = require('./common')
|
||||
|
||||
Promise.try ->
|
||||
if not params.deviceIp?
|
||||
return forms.selectLocalResinOsDevice()
|
||||
return forms.selectLocalBalenaOsDevice()
|
||||
return params.deviceIp
|
||||
.then (@deviceIp) =>
|
||||
if not options['app-name']?
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -14,43 +14,57 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
# Loads '.resin-sync.yml' configuration from 'source' directory.
|
||||
# Loads '.balena-sync.yml' configuration from 'source' directory.
|
||||
# Returns the configuration object on success
|
||||
#
|
||||
|
||||
_ = require('lodash')
|
||||
|
||||
resinPush = require('resin-sync').capitano('resin-toolbox')
|
||||
balenaPush = require('balena-sync').capitano('balena-toolbox')
|
||||
originalAction = balenaPush.action
|
||||
|
||||
# TODO: This is a temporary workaround to reuse the existing `rdt push`
|
||||
# capitano frontend in `resin local push`.
|
||||
# capitano frontend in `balena local push`.
|
||||
|
||||
resinPushHelp = '''
|
||||
Warning: 'resin local push' requires an openssh-compatible client and 'rsync' to
|
||||
be correctly installed in your shell environment. For more information (including
|
||||
Windows support) please check the README here: https://github.com/resin-io/resin-cli
|
||||
# coffeelint: disable-next-line ("Line ends with trailing whitespace")
|
||||
deprecationMsg = '''
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Deprecation notice: `balena local push` is deprecated and will be removed in a
|
||||
future release of the CLI. Please use `balena push <ipAddress>` instead.
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
Use this command to push your local changes to a container on a LAN-accessible resinOS device on the fly.
|
||||
'''
|
||||
|
||||
balenaPushHelp = """#{deprecationMsg}
|
||||
Use this command to push your local changes to a container on a LAN-accessible
|
||||
balenaOS device on the fly.
|
||||
|
||||
This command requires an openssh-compatible 'ssh' client and 'rsync' to be
|
||||
available in the executable PATH of the shell environment. For more information
|
||||
(including Windows support) please check the README at:
|
||||
https://github.com/balena-io/balena-cli
|
||||
|
||||
If `Dockerfile` or any file in the 'build-triggers' list is changed,
|
||||
a new container will be built and run on your device.
|
||||
If not, changes will simply be synced with `rsync` into the application container.
|
||||
|
||||
After every 'resin local push' the updated settings will be saved in
|
||||
'<source>/.resin-sync.yml' and will be used in later invocations. You can
|
||||
also change any option by editing '.resin-sync.yml' directly.
|
||||
After every 'balena local push' the updated settings will be saved in
|
||||
'<source>/.balena-sync.yml' and will be used in later invocations. You can
|
||||
also change any option by editing '.balena-sync.yml' directly.
|
||||
|
||||
Here is an example '.resin-sync.yml' :
|
||||
Here is an example '.balena-sync.yml' :
|
||||
|
||||
$ cat $PWD/.resin-sync.yml
|
||||
destination: '/usr/src/app'
|
||||
before: 'echo Hello'
|
||||
after: 'echo Done'
|
||||
ignore:
|
||||
- .git
|
||||
- node_modules/
|
||||
$ cat $PWD/.balena-sync.yml
|
||||
local_balenaos:
|
||||
app-name: local-app
|
||||
build-triggers:
|
||||
- Dockerfile: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
|
||||
- package.json: file-hash-abcdefabcdefabcdefabcdefabcdefabcdef
|
||||
environment:
|
||||
- MY_VARIABLE=123
|
||||
|
||||
Command line options have precedence over the ones saved in '.resin-sync.yml'.
|
||||
|
||||
Command line options have precedence over the ones saved in '.balena-sync.yml'.
|
||||
|
||||
If '.gitignore' is found in the source directory then all explicitly listed files will be
|
||||
excluded when using rsync to update the container. You can choose to change this default behavior with the
|
||||
@ -58,19 +72,24 @@ resinPushHelp = '''
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local push
|
||||
$ resin local push --app-name test-server --build-triggers package.json,requirements.txt
|
||||
$ resin local push --force-build
|
||||
$ resin local push --force-build --skip-logs
|
||||
$ resin local push --ignore lib/
|
||||
$ resin local push --verbose false
|
||||
$ resin local push 192.168.2.10 --source . --destination /usr/src/app
|
||||
$ resin local push 192.168.2.10 -s /home/user/myResinProject -d /usr/src/app --before 'echo Hello' --after 'echo Done'
|
||||
'''
|
||||
$ balena local push
|
||||
$ balena local push --app-name test-server --build-triggers package.json,requirements.txt
|
||||
$ balena local push --force-build
|
||||
$ balena local push --force-build --skip-logs
|
||||
$ balena local push --ignore lib/
|
||||
$ balena local push --verbose false
|
||||
$ balena local push 192.168.2.10 --source . --destination /usr/src/app
|
||||
$ balena local push 192.168.2.10 -s /home/user/balenaProject -d /usr/src/app --before 'echo Hello' --after 'echo Done'
|
||||
"""
|
||||
|
||||
|
||||
module.exports = _.assign resinPush,
|
||||
|
||||
module.exports = _.assign balenaPush,
|
||||
signature: 'local push [deviceIp]'
|
||||
help: resinPushHelp
|
||||
primary: true
|
||||
description: '[deprecated: use "balena push ipAddress"] ' + balenaPush.description
|
||||
help: balenaPushHelp
|
||||
primary: false
|
||||
root: true
|
||||
action: (params, options, done) ->
|
||||
console.log deprecationMsg
|
||||
originalAction(params, options, done)
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -34,14 +34,14 @@ dockerVersionProperties = [
|
||||
|
||||
module.exports =
|
||||
signature: 'local scan'
|
||||
description: 'Scan for resinOS devices in your local network'
|
||||
description: 'Scan for balenaOS devices in your local network'
|
||||
help: '''
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local scan
|
||||
$ resin local scan --timeout 120
|
||||
$ resin local scan --verbose
|
||||
$ balena local scan
|
||||
$ balena local scan --timeout 120
|
||||
$ balena local scan --verbose
|
||||
'''
|
||||
options: [
|
||||
signature: 'verbose'
|
||||
@ -60,30 +60,31 @@ module.exports =
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
prettyjson = require('prettyjson')
|
||||
Docker = require('docker-toolbelt')
|
||||
{ discover } = require('resin-sync')
|
||||
{ discover } = require('balena-sync')
|
||||
{ SpinnerPromise } = require('resin-cli-visuals')
|
||||
{ dockerPort, dockerTimeout } = require('./common')
|
||||
dockerUtils = require('../../utils/docker')
|
||||
{ exitWithExpectedError } = require('../../utils/patterns')
|
||||
|
||||
if options.timeout?
|
||||
options.timeout *= 1000
|
||||
|
||||
Promise.try ->
|
||||
new SpinnerPromise
|
||||
promise: discover.discoverLocalResinOsDevices(options.timeout)
|
||||
startMessage: 'Scanning for local resinOS devices..'
|
||||
promise: discover.discoverLocalBalenaOsDevices(options.timeout)
|
||||
startMessage: 'Scanning for local balenaOS devices..'
|
||||
stopMessage: 'Reporting scan results'
|
||||
.filter ({ address }) ->
|
||||
Promise.try ->
|
||||
docker = new Docker(host: address, port: dockerPort, timeout: dockerTimeout)
|
||||
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
|
||||
docker.pingAsync()
|
||||
.return(true)
|
||||
.catchReturn(false)
|
||||
.tap (devices) ->
|
||||
if _.isEmpty(devices)
|
||||
throw new Error('Could not find any resinOS devices in the local network')
|
||||
exitWithExpectedError('Could not find any balenaOS devices in the local network')
|
||||
.map ({ host, address }) ->
|
||||
docker = new Docker(host: address, port: dockerPort, timeout: dockerTimeout)
|
||||
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
|
||||
Promise.props
|
||||
dockerInfo: docker.infoAsync().catchReturn('Could not get Docker info')
|
||||
dockerVersion: docker.versionAsync().catchReturn('Could not get Docker version')
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -14,28 +14,34 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
{ hostOSAccess } = require('../command-options')
|
||||
_ = require('lodash')
|
||||
|
||||
localHostOSAccessOption = _.cloneDeep(hostOSAccess)
|
||||
localHostOSAccessOption.description = 'get a shell into the host OS'
|
||||
|
||||
module.exports =
|
||||
signature: 'local ssh [deviceIp]'
|
||||
description: 'Get a shell into a resinOS device'
|
||||
description: 'Get a shell into a balenaOS device'
|
||||
help: '''
|
||||
Warning: 'resin local ssh' requires an openssh-compatible client to be correctly
|
||||
Warning: 'balena local ssh' requires an openssh-compatible client to be correctly
|
||||
installed in your shell environment. For more information (including Windows
|
||||
support) please check the README here: https://github.com/resin-io/resin-cli
|
||||
support) please check the README here: https://github.com/balena-io/balena-cli
|
||||
|
||||
Use this command to get a shell into the running application container of
|
||||
your device.
|
||||
|
||||
The '--host' option will get you a shell into the Host OS of the resinOS device.
|
||||
The '--host' option will get you a shell into the Host OS of the balenaOS device.
|
||||
No option will return a list of containers to enter or you can explicitly select
|
||||
one by passing its name to the --container option
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local ssh
|
||||
$ resin local ssh --host
|
||||
$ resin local ssh --container chaotic_water
|
||||
$ resin local ssh --container chaotic_water --port 22222
|
||||
$ resin local ssh --verbose
|
||||
$ balena local ssh
|
||||
$ balena local ssh --host
|
||||
$ balena local ssh --container chaotic_water
|
||||
$ balena local ssh --container chaotic_water --port 22222
|
||||
$ balena local ssh --verbose
|
||||
'''
|
||||
options: [
|
||||
signature: 'verbose'
|
||||
@ -43,11 +49,7 @@ module.exports =
|
||||
description: 'increase verbosity'
|
||||
alias: 'v'
|
||||
,
|
||||
signature: 'host'
|
||||
boolean: true
|
||||
description: 'get a shell into the host OS'
|
||||
alias: 's'
|
||||
,
|
||||
localHostOSAccessOption,
|
||||
signature: 'container'
|
||||
parameter: 'container'
|
||||
default: null
|
||||
@ -64,11 +66,13 @@ module.exports =
|
||||
child_process = require('child_process')
|
||||
Promise = require 'bluebird'
|
||||
_ = require('lodash')
|
||||
{ forms } = require('resin-sync')
|
||||
{ forms } = require('balena-sync')
|
||||
|
||||
{ selectContainerFromDevice, getSubShellCommand } = require('./common')
|
||||
{ exitWithExpectedError } = require('../../utils/patterns')
|
||||
|
||||
if (options.host is true and options.container?)
|
||||
throw new Error('Please pass either --host or --container option')
|
||||
exitWithExpectedError('Please pass either --host or --container option')
|
||||
|
||||
if not options.port?
|
||||
options.port = 22222
|
||||
@ -77,7 +81,7 @@ module.exports =
|
||||
|
||||
Promise.try ->
|
||||
if not params.deviceIp?
|
||||
return forms.selectLocalResinOsDevice()
|
||||
return forms.selectLocalBalenaOsDevice()
|
||||
return params.deviceIp
|
||||
.then (deviceIp) ->
|
||||
_.assign(options, { deviceIp })
|
||||
@ -101,7 +105,8 @@ module.exports =
|
||||
|
||||
if not options.host
|
||||
shellCmd = '''/bin/sh -c $"'if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi'"'''
|
||||
command += " docker exec -ti #{container} #{shellCmd}"
|
||||
dockerCmd = "'$(if [ -f /usr/bin/balena ]; then echo \"balena\"; else echo \"docker\"; fi)'"
|
||||
command += " #{dockerCmd} exec -ti #{container} #{shellCmd}"
|
||||
|
||||
subShellCommand = getSubShellCommand(command)
|
||||
child_process.spawn subShellCommand.program, subShellCommand.args,
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2017 Resin.io
|
||||
Copyright 2017 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -22,16 +22,16 @@ limitations under the License.
|
||||
#
|
||||
module.exports =
|
||||
signature: 'local stop [deviceIp]'
|
||||
description: 'Stop a running container on a resinOS device'
|
||||
description: 'Stop a running container on a balenaOS device'
|
||||
help: '''
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin local stop
|
||||
$ resin local stop --app-name myapp
|
||||
$ resin local stop --all
|
||||
$ resin local stop 192.168.1.10
|
||||
$ resin local stop 192.168.1.10 --app-name myapp
|
||||
$ balena local stop
|
||||
$ balena local stop --app-name myapp
|
||||
$ balena local stop --all
|
||||
$ balena local stop 192.168.1.10
|
||||
$ balena local stop 192.168.1.10 --app-name myapp
|
||||
'''
|
||||
options: [
|
||||
signature: 'all'
|
||||
@ -47,15 +47,15 @@ module.exports =
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
chalk = require('chalk')
|
||||
{ forms, config, ResinLocalDockerUtils } = require('resin-sync')
|
||||
{ forms, config, BalenaLocalDockerUtils } = require('balena-sync')
|
||||
{ selectContainerFromDevice, filterOutSupervisorContainer } = require('./common')
|
||||
|
||||
Promise.try ->
|
||||
if not params.deviceIp?
|
||||
return forms.selectLocalResinOsDevice()
|
||||
return forms.selectLocalBalenaOsDevice()
|
||||
return params.deviceIp
|
||||
.then (@deviceIp) =>
|
||||
@docker = new ResinLocalDockerUtils(@deviceIp)
|
||||
@docker = new BalenaLocalDockerUtils(@deviceIp)
|
||||
|
||||
if options.all
|
||||
# Only list running containers
|
||||
@ -67,7 +67,7 @@ module.exports =
|
||||
@docker.stopContainer(Id)
|
||||
|
||||
ymlConfig = config.load()
|
||||
@appName = options['app-name'] ? ymlConfig['local_resinos']?['app-name']
|
||||
@appName = options['app-name'] ? ymlConfig['local_balenaos']?['app-name']
|
||||
@docker.checkForRunningContainer(@appName)
|
||||
.then (isRunning) =>
|
||||
if not isRunning
|
||||
|
@ -1,69 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
module.exports =
|
||||
signature: 'logs <uuid>'
|
||||
description: 'show device logs'
|
||||
help: '''
|
||||
Use this command to show logs for a specific device.
|
||||
|
||||
By default, the command prints all log messages and exit.
|
||||
|
||||
To continuously stream output, and see new logs in real time, use the `--tail` option.
|
||||
|
||||
Note that for now you need to provide the whole UUID for this command to work correctly.
|
||||
|
||||
This is due to some technical limitations that we plan to address soon.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin logs 23c73a1
|
||||
$ resin logs 23c73a1
|
||||
'''
|
||||
options: [
|
||||
{
|
||||
signature: 'tail'
|
||||
description: 'continuously stream output'
|
||||
boolean: true
|
||||
alias: 't'
|
||||
}
|
||||
]
|
||||
permission: 'user'
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
_ = require('lodash')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
moment = require('moment')
|
||||
|
||||
printLine = (line) ->
|
||||
timestamp = moment(line.timestamp).format('DD.MM.YY HH:mm:ss (ZZ)')
|
||||
console.log("#{timestamp} #{line.message}")
|
||||
|
||||
promise = resin.logs.history(params.uuid).each(printLine)
|
||||
|
||||
if not options.tail
|
||||
|
||||
# PubNub keeps the process alive after a history query.
|
||||
# Until this is fixed, we force the process to exit.
|
||||
# This of course prevents this command to be used programatically
|
||||
return promise.catch(done).finally ->
|
||||
process.exit(0)
|
||||
|
||||
promise.then ->
|
||||
resin.logs.subscribe(params.uuid).then (logs) ->
|
||||
logs.on('line', printLine)
|
||||
logs.on('error', done)
|
||||
.catch(done)
|
172
lib/actions/logs.ts
Normal file
172
lib/actions/logs.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/*
|
||||
Copyright 2016-2019 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 { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
import { normalizeUuidProp } from '../utils/normalization';
|
||||
import { validateDotLocalUrl } from '../utils/validation';
|
||||
|
||||
type CloudLog =
|
||||
| {
|
||||
isSystem: false;
|
||||
serviceId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
isSystem: true;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const logs: CommandDefinition<
|
||||
{
|
||||
uuidOrDevice: string;
|
||||
},
|
||||
{
|
||||
tail: boolean;
|
||||
service: string;
|
||||
system: boolean;
|
||||
}
|
||||
> = {
|
||||
signature: 'logs <uuidOrDevice>',
|
||||
description: 'show device logs',
|
||||
help: stripIndent`
|
||||
Use this command to show logs for a specific device.
|
||||
|
||||
By default, the command prints all log messages and exits.
|
||||
|
||||
To continuously stream output, and see new logs in real time, use the \`--tail\` option.
|
||||
|
||||
If an IP or .local address is passed to this command, logs are displayed from
|
||||
a local mode device with that address. Note that --tail is implied
|
||||
when this command is provided a local mode device.
|
||||
|
||||
Logs from a single service can be displayed with the --service flag. Just system logs
|
||||
can be shown with the --system flag. Note that these flags can be used together.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena logs 23c73a1
|
||||
$ balena logs 23c73a1 --tail
|
||||
|
||||
$ balena logs 192.168.0.31
|
||||
$ balena logs 192.168.0.31 --service my-service
|
||||
|
||||
$ balena logs 23c73a1.local --system
|
||||
$ balena logs 23c73a1.local --system --service my-service`,
|
||||
options: [
|
||||
{
|
||||
signature: 'tail',
|
||||
description: 'continuously stream output',
|
||||
boolean: true,
|
||||
alias: 't',
|
||||
},
|
||||
{
|
||||
signature: 'service',
|
||||
description:
|
||||
'Only show logs for a single service. This can be used in combination with --system',
|
||||
parameter: 'service',
|
||||
alias: 's',
|
||||
},
|
||||
{
|
||||
signature: 'system',
|
||||
alias: 'S',
|
||||
boolean: true,
|
||||
description:
|
||||
'Only show system logs. This can be used in combination with --service.',
|
||||
},
|
||||
],
|
||||
permission: 'user',
|
||||
primary: true,
|
||||
async action(params, options, done) {
|
||||
normalizeUuidProp(params);
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const { serviceIdToName } = await import('../utils/cloud');
|
||||
const { displayDeviceLogs, displayLogObject } = await import(
|
||||
'../utils/device/logs'
|
||||
);
|
||||
const { validateIPAddress } = await import('../utils/validation');
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
const Logger = await import('../utils/logger');
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
const displayCloudLog = async (line: CloudLog) => {
|
||||
if (!line.isSystem) {
|
||||
let serviceName = await serviceIdToName(balena, line.serviceId);
|
||||
if (serviceName == null) {
|
||||
serviceName = 'Unknown service';
|
||||
}
|
||||
displayLogObject(
|
||||
{ serviceName, ...line },
|
||||
logger,
|
||||
options.system || false,
|
||||
options.service,
|
||||
);
|
||||
} else {
|
||||
displayLogObject(
|
||||
line,
|
||||
logger,
|
||||
options.system || false,
|
||||
options.service,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
validateIPAddress(params.uuidOrDevice) ||
|
||||
validateDotLocalUrl(params.uuidOrDevice)
|
||||
) {
|
||||
const { DeviceAPI } = await import('../utils/device/api');
|
||||
const deviceApi = new DeviceAPI(logger, params.uuidOrDevice);
|
||||
logger.logDebug('Checking we can access device');
|
||||
try {
|
||||
await deviceApi.ping();
|
||||
} catch (e) {
|
||||
exitWithExpectedError(
|
||||
new Error(
|
||||
`Cannot access local mode device at address ${params.uuidOrDevice}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const logStream = await deviceApi.getLogStream();
|
||||
displayDeviceLogs(
|
||||
logStream,
|
||||
logger,
|
||||
options.system || false,
|
||||
options.service,
|
||||
);
|
||||
} else {
|
||||
if (options.tail) {
|
||||
return balena.logs
|
||||
.subscribe(params.uuidOrDevice, { count: 100 })
|
||||
.then(function(logStream) {
|
||||
logStream.on('line', displayCloudLog);
|
||||
logStream.on('error', done);
|
||||
})
|
||||
.catch(done);
|
||||
} else {
|
||||
return balena.logs
|
||||
.history(params.uuidOrDevice)
|
||||
.each(displayCloudLog)
|
||||
.catch(done);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
exports.set =
|
||||
signature: 'note <|note>'
|
||||
description: 'set a device note'
|
||||
@ -22,12 +24,12 @@ exports.set =
|
||||
|
||||
If note command isn't passed, the tool attempts to read from `stdin`.
|
||||
|
||||
To view the notes, use $ resin device <uuid>.
|
||||
To view the notes, use $ balena device <uuid>.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin note "My useful note" --device 7cf02a6
|
||||
$ cat note.txt | resin note --device 7cf02a6
|
||||
$ balena note "My useful note" --device 7cf02a6
|
||||
$ cat note.txt | balena note --device 7cf02a6
|
||||
'''
|
||||
options: [
|
||||
signature: 'device'
|
||||
@ -38,13 +40,16 @@ exports.set =
|
||||
]
|
||||
permission: 'user'
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(options, 'device')
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
Promise.try ->
|
||||
if _.isEmpty(params.note)
|
||||
throw new Error('Missing note content')
|
||||
exitWithExpectedError('Missing note content')
|
||||
|
||||
resin.models.device.note(options.device, params.note)
|
||||
balena.models.device.note(options.device, params.note)
|
||||
.nodeify(done)
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
_ = require('lodash')
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
formatVersion = (v, isRecommended) ->
|
||||
result = "v#{v}"
|
||||
@ -30,9 +31,9 @@ resolveVersion = (deviceType, version) ->
|
||||
return Promise.resolve(version)
|
||||
|
||||
form = require('resin-cli-form')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
resin.models.os.getSupportedVersions(deviceType)
|
||||
balena.models.os.getSupportedVersions(deviceType)
|
||||
.then ({ versions, recommended }) ->
|
||||
choices = versions.map (v) ->
|
||||
value: v
|
||||
@ -46,19 +47,19 @@ resolveVersion = (deviceType, version) ->
|
||||
|
||||
exports.versions =
|
||||
signature: 'os versions <type>'
|
||||
description: 'show the available resinOS versions for the given device type'
|
||||
description: 'show the available balenaOS versions for the given device type'
|
||||
help: '''
|
||||
Use this command to show the available resinOS versions for a certain device type.
|
||||
Check available types with `resin devices supported`
|
||||
Use this command to show the available balenaOS versions for a certain device type.
|
||||
Check available types with `balena devices supported`
|
||||
|
||||
Example:
|
||||
|
||||
$ resin os versions raspberrypi3
|
||||
$ balena os versions raspberrypi3
|
||||
'''
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
resin.models.os.getSupportedVersions(params.type)
|
||||
balena.models.os.getSupportedVersions(params.type)
|
||||
.then ({ versions, recommended }) ->
|
||||
versions.forEach (v) ->
|
||||
console.log(formatVersion(v, v is recommended))
|
||||
@ -68,7 +69,7 @@ exports.download =
|
||||
description: 'download an unconfigured os image'
|
||||
help: '''
|
||||
Use this command to download an unconfigured os image for a certain device type.
|
||||
Check available types with `resin devices supported`
|
||||
Check available types with `balena devices supported`
|
||||
|
||||
If version is not specified the newest stable (non-pre-release) version of OS
|
||||
is downloaded if available, or the newest version otherwise (if all existing
|
||||
@ -79,12 +80,12 @@ exports.download =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version 1.24.1
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version ^1.20.0
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version latest
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version default
|
||||
$ resin os download raspberrypi3 -o ../foo/bar/raspberry-pi.img --version menu
|
||||
$ 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 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
|
||||
'''
|
||||
permission: 'user'
|
||||
options: [
|
||||
@ -95,14 +96,14 @@ exports.download =
|
||||
alias: 'o'
|
||||
required: 'You have to specify the output location'
|
||||
}
|
||||
commandOptions.osVersion
|
||||
commandOptions.osVersionOrSemver
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
unzip = require('unzip2')
|
||||
fs = require('fs')
|
||||
rindle = require('rindle')
|
||||
manager = require('resin-image-manager')
|
||||
manager = require('balena-image-manager')
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
console.info("Getting device operating system for #{params.type}")
|
||||
@ -146,33 +147,39 @@ exports.download =
|
||||
console.info('The image was downloaded successfully')
|
||||
.nodeify(done)
|
||||
|
||||
buildConfig = (image, deviceType, advanced = false) ->
|
||||
buildConfigForDeviceType = (deviceType, advanced = false) ->
|
||||
form = require('resin-cli-form')
|
||||
helpers = require('../utils/helpers')
|
||||
|
||||
helpers.getManifest(image, deviceType)
|
||||
.get('options')
|
||||
.then (questions) ->
|
||||
if not advanced
|
||||
advancedGroup = _.find questions,
|
||||
name: 'advanced'
|
||||
isGroup: true
|
||||
questions = deviceType.options
|
||||
if not advanced
|
||||
advancedGroup = _.find questions,
|
||||
name: 'advanced'
|
||||
isGroup: true
|
||||
|
||||
if advancedGroup?
|
||||
override = helpers.getGroupDefaults(advancedGroup)
|
||||
if advancedGroup?
|
||||
override = helpers.getGroupDefaults(advancedGroup)
|
||||
|
||||
return form.run(questions, { override })
|
||||
return form.run(questions, { override })
|
||||
|
||||
buildConfig = (image, deviceTypeSlug, advanced = false) ->
|
||||
Promise = require('bluebird')
|
||||
helpers = require('../utils/helpers')
|
||||
|
||||
Promise.resolve(helpers.getManifest(image, deviceTypeSlug))
|
||||
.then (deviceTypeManifest) ->
|
||||
buildConfigForDeviceType(deviceTypeManifest, advanced)
|
||||
|
||||
exports.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 `resin os configure`.
|
||||
Use this command to prebuild the OS config once and skip the interactive part of `balena os configure`.
|
||||
|
||||
Example:
|
||||
|
||||
$ resin os build-config ../path/rpi3.img raspberrypi3 --output rpi3-config.json
|
||||
$ resin os configure ../path/rpi3.img 7cf02a6 --config "$(cat rpi3-config.json)"
|
||||
$ 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: [
|
||||
@ -196,23 +203,32 @@ exports.buildConfig =
|
||||
.nodeify(done)
|
||||
|
||||
exports.configure =
|
||||
signature: 'os configure <image> [uuid] [deviceApiKey]'
|
||||
signature: 'os configure <image>'
|
||||
description: 'configure an os image'
|
||||
help: '''
|
||||
Use this command to configure a previously downloaded operating system image for
|
||||
the specific device or for an application generally.
|
||||
|
||||
Note that device api keys are only supported on ResinOS 2.0.3+.
|
||||
This command will try to automatically determine the operating system version in order
|
||||
to correctly configure the image. It may fail to do so however, in which case you'll
|
||||
have to call this command again with the exact version number of the targeted image.
|
||||
|
||||
This comand still supports the *deprecated* format where the UUID and optionally device key
|
||||
Note that device api keys are only supported on balenaOS 2.0.3+.
|
||||
|
||||
This command still supports the *deprecated* format where the UUID and optionally device key
|
||||
are passed directly on the command line, but the recommended way is to pass either an --app or
|
||||
--device argument. The deprecated format will be remove in a future release.
|
||||
--device argument. The deprecated format will be removed in a future release.
|
||||
|
||||
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:
|
||||
|
||||
$ resin os configure ../path/rpi.img --device 7cf02a6
|
||||
$ resin os configure ../path/rpi.img --device 7cf02a6 --deviceApiKey <existingDeviceKey>
|
||||
$ resin os configure ../path/rpi.img --app MyApp
|
||||
$ balena os configure ../path/rpi3.img --device 7cf02a6
|
||||
$ balena os configure ../path/rpi3.img --device 7cf02a6 --device-api-key <existingDeviceKey>
|
||||
$ balena os configure ../path/rpi3.img --app MyApp
|
||||
$ balena os configure ../path/rpi3.img --app MyApp --version 2.12.7
|
||||
$ balena os configure ../path/rpi3.img --app MyFinApp --device-type raspberrypi3
|
||||
'''
|
||||
permission: 'user'
|
||||
options: [
|
||||
@ -220,18 +236,21 @@ exports.configure =
|
||||
commandOptions.optionalApplication
|
||||
commandOptions.optionalDevice
|
||||
commandOptions.optionalDeviceApiKey
|
||||
commandOptions.optionalDeviceType
|
||||
commandOptions.optionalOsVersion
|
||||
{
|
||||
signature: 'config'
|
||||
description: 'path to the config JSON file, see `resin os build-config`'
|
||||
description: 'path to the config JSON file, see `balena os build-config`'
|
||||
parameter: 'config'
|
||||
}
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(options, 'device')
|
||||
fs = require('fs')
|
||||
Promise = require('bluebird')
|
||||
readFileAsync = Promise.promisify(fs.readFile)
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
init = require('resin-device-init')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
init = require('balena-device-init')
|
||||
helpers = require('../utils/helpers')
|
||||
patterns = require('../utils/patterns')
|
||||
{ generateDeviceConfig, generateApplicationConfig } = require('../utils/config')
|
||||
@ -239,48 +258,84 @@ exports.configure =
|
||||
if _.filter([
|
||||
options.device
|
||||
options.application
|
||||
params.uuid
|
||||
]).length != 1
|
||||
patterns.expectedError '''
|
||||
patterns.exitWithExpectedError '''
|
||||
To configure an image, you must provide exactly one of:
|
||||
|
||||
* A device, with --device <uuid>
|
||||
* An application, with --app <appname>
|
||||
* [Deprecated] A device, passing its uuid directly on the command line
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ resin help os configure
|
||||
$ balena help os configure
|
||||
'''
|
||||
if params.uuid
|
||||
console.warn(
|
||||
'Directly passing a UUID to `resin os configure` is deprecated. Pass it with --uuid <uuid> instead.' +
|
||||
if params.deviceApiKey
|
||||
' Device api keys can be passed with --deviceApiKey.\n'
|
||||
else '\n'
|
||||
)
|
||||
|
||||
uuid = options.device || params.uuid
|
||||
deviceApiKey = options.deviceApiKey || params.deviceApiKey
|
||||
if !options.application and options.deviceType
|
||||
patterns.exitWithExpectedError '''
|
||||
Specifying a different device type is only supported when
|
||||
configuring an image using an application as a parameter:
|
||||
|
||||
* An application, with --app <appname>
|
||||
* A specific device type, with --device-type <deviceTypeSlug>
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ balena help os configure
|
||||
'''
|
||||
|
||||
uuid = options.device
|
||||
deviceApiKey = options.deviceApiKey
|
||||
|
||||
console.info('Configuring operating system image')
|
||||
|
||||
configurationResourceType = if uuid then 'device' else 'application'
|
||||
|
||||
resin.models[configurationResourceType].get(uuid || options.application)
|
||||
balena.models[configurationResourceType].get(uuid || options.application)
|
||||
.then (appOrDevice) ->
|
||||
Promise.try ->
|
||||
deviceType = options.deviceType || appOrDevice.device_type
|
||||
manifestPromise = helpers.getManifest(params.image, deviceType)
|
||||
|
||||
if options.application && options.deviceType
|
||||
app = appOrDevice
|
||||
appManifestPromise = balena.models.device.getManifestBySlug(app.device_type)
|
||||
paramManifestPromise = balena.models.device.getManifestBySlug(options.deviceType)
|
||||
manifestPromise = Promise.resolve(manifestPromise).tap ->
|
||||
Promise.join appManifestPromise, paramManifestPromise, (appDeviceType, paramDeviceType) ->
|
||||
if not helpers.areDeviceTypesCompatible(appDeviceType, paramDeviceType)
|
||||
throw new balena.errors.BalenaInvalidDeviceType(
|
||||
"Device type #{options.deviceType} is incompatible with application #{options.application}"
|
||||
)
|
||||
|
||||
answersPromise = Promise.try ->
|
||||
if options.config
|
||||
return readFileAsync(options.config, 'utf8')
|
||||
.then(JSON.parse)
|
||||
return buildConfig(params.image, appOrDevice.device_type, options.advanced)
|
||||
.then (answers) ->
|
||||
(if configurationResourceType == 'device'
|
||||
generateDeviceConfig(appOrDevice, deviceApiKey, answers)
|
||||
else
|
||||
generateApplicationConfig(appOrDevice, answers)
|
||||
).then (config) ->
|
||||
init.configure(params.image, appOrDevice.device_type, config, answers)
|
||||
return manifestPromise.then (deviceTypeManifest) ->
|
||||
buildConfigForDeviceType(deviceTypeManifest, options.advanced)
|
||||
|
||||
Promise.join answersPromise, manifestPromise, (answers, manifest) ->
|
||||
answers.version = options.version
|
||||
|
||||
if configurationResourceType == 'application'
|
||||
answers.deviceType = deviceType
|
||||
|
||||
if not answers.version?
|
||||
answers.version = Promise.resolve(helpers.getOsVersion(params.image, manifest)).tap (version) ->
|
||||
if not version?
|
||||
throw new Error(
|
||||
'Could not read OS version from the image. ' +
|
||||
'Please specify the version manually with the ' +
|
||||
'--version argument to this command.'
|
||||
)
|
||||
|
||||
Promise.props(answers).then (answers) ->
|
||||
(if configurationResourceType == 'device'
|
||||
generateDeviceConfig(appOrDevice, deviceApiKey, answers)
|
||||
else
|
||||
generateApplicationConfig(appOrDevice, answers)
|
||||
)
|
||||
.then (config) ->
|
||||
init.configure(params.image, manifest, config, answers)
|
||||
.then(helpers.osProgressHandler)
|
||||
.nodeify(done)
|
||||
|
||||
@ -299,14 +354,14 @@ exports.initialize =
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin os initialize ../path/rpi.img --type 'raspberry-pi'
|
||||
$ balena os initialize ../path/rpi.img --type 'raspberry-pi'
|
||||
"""
|
||||
permission: 'user'
|
||||
options: [
|
||||
commandOptions.yes
|
||||
{
|
||||
signature: 'type'
|
||||
description: 'device type (Check available types with `resin devices supported`)'
|
||||
description: 'device type (Check available types with `balena devices supported`)'
|
||||
parameter: 'type'
|
||||
alias: 't'
|
||||
required: 'You have to specify a device type'
|
||||
@ -325,7 +380,7 @@ exports.initialize =
|
||||
|
||||
#{INIT_WARNING_MESSAGE}
|
||||
""")
|
||||
helpers.getManifest(params.image, options.type)
|
||||
Promise.resolve(helpers.getManifest(params.image, options.type))
|
||||
.then (manifest) ->
|
||||
return manifest.initialization?.options
|
||||
.then (questions) ->
|
||||
@ -352,7 +407,7 @@ exports.initialize =
|
||||
.then (answers) ->
|
||||
return if not answers.drive?
|
||||
|
||||
# TODO: resin local makes use of ejectAsync, see below
|
||||
# TODO: balena local makes use of ejectAsync, see below
|
||||
# DO we need this / should we do that here?
|
||||
|
||||
# getDrive = (drive) ->
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -16,40 +16,66 @@ limitations under the License.
|
||||
|
||||
dockerUtils = require('../utils/docker')
|
||||
|
||||
LATEST = 'latest'
|
||||
allDeviceTypes = undefined
|
||||
|
||||
isCurrent = (commit) ->
|
||||
return commit == 'latest' or commit == 'current'
|
||||
|
||||
getDeviceTypes = ->
|
||||
Bluebird = require('bluebird')
|
||||
_ = require('lodash')
|
||||
if allDeviceTypes != undefined
|
||||
return Bluebird.resolve(allDeviceTypes)
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
balena.models.config.getDeviceTypes()
|
||||
.then (deviceTypes) ->
|
||||
_.sortBy(deviceTypes, 'name')
|
||||
.tap (dt) ->
|
||||
allDeviceTypes = dt
|
||||
|
||||
getDeviceTypesWithSameArch = (deviceTypeSlug) ->
|
||||
_ = require('lodash')
|
||||
|
||||
getDeviceTypes()
|
||||
.then (deviceTypes) ->
|
||||
deviceType = _.find(deviceTypes, slug: deviceTypeSlug)
|
||||
_(deviceTypes).filter(arch: deviceType.arch).map('slug').value()
|
||||
|
||||
getApplicationsWithSuccessfulBuilds = (deviceType) ->
|
||||
preload = require('resin-preload')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
preload = require('balena-preload')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
|
||||
resin.pine.get
|
||||
resource: 'my_application'
|
||||
options:
|
||||
filter:
|
||||
device_type: deviceType
|
||||
build:
|
||||
$any:
|
||||
$alias: 'b'
|
||||
$expr:
|
||||
b:
|
||||
status: 'success'
|
||||
expand: preload.applicationExpandOptions
|
||||
select: [ 'id', 'app_name', 'device_type', 'commit' ]
|
||||
orderby: 'app_name asc'
|
||||
getDeviceTypesWithSameArch(deviceType)
|
||||
.then (deviceTypes) ->
|
||||
balena.pine.get
|
||||
resource: 'my_application'
|
||||
options:
|
||||
$filter:
|
||||
device_type:
|
||||
$in: deviceTypes
|
||||
owns__release:
|
||||
$any:
|
||||
$alias: 'r'
|
||||
$expr:
|
||||
r:
|
||||
status: 'success'
|
||||
$expand: preload.applicationExpandOptions
|
||||
$select: [ 'id', 'app_name', 'device_type', 'commit', 'should_track_latest_release' ]
|
||||
$orderby: 'app_name asc'
|
||||
|
||||
selectApplication = (deviceType) ->
|
||||
visuals = require('resin-cli-visuals')
|
||||
form = require('resin-cli-form')
|
||||
{ expectedError } = require('../utils/patterns')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and builds.')
|
||||
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and releases.')
|
||||
applicationInfoSpinner.start()
|
||||
|
||||
getApplicationsWithSuccessfulBuilds(deviceType)
|
||||
.then (applications) ->
|
||||
applicationInfoSpinner.stop()
|
||||
if applications.length == 0
|
||||
expectedError("You have no apps with successful builds for a '#{deviceType}' device type.")
|
||||
exitWithExpectedError("You have no apps with successful releases for a '#{deviceType}' device type.")
|
||||
form.ask
|
||||
message: 'Select an application'
|
||||
type: 'list'
|
||||
@ -57,36 +83,41 @@ selectApplication = (deviceType) ->
|
||||
name: app.app_name
|
||||
value: app
|
||||
|
||||
selectApplicationCommit = (builds) ->
|
||||
selectApplicationCommit = (releases) ->
|
||||
form = require('resin-cli-form')
|
||||
{ expectedError } = require('../utils/patterns')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
if builds.length == 0
|
||||
expectedError('This application has no successful builds.')
|
||||
DEFAULT_CHOICE = {'name': LATEST, 'value': LATEST}
|
||||
choices = [ DEFAULT_CHOICE ].concat builds.map (build) ->
|
||||
name: "#{build.push_timestamp} - #{build.commit_hash}"
|
||||
value: build.commit_hash
|
||||
if releases.length == 0
|
||||
exitWithExpectedError('This application has no successful releases.')
|
||||
DEFAULT_CHOICE = { 'name': 'current', 'value': 'current' }
|
||||
choices = [ DEFAULT_CHOICE ].concat releases.map (release) ->
|
||||
name: "#{release.end_timestamp} - #{release.commit}"
|
||||
value: release.commit
|
||||
return form.ask
|
||||
message: 'Select a build'
|
||||
message: 'Select a release'
|
||||
type: 'list'
|
||||
default: LATEST
|
||||
default: 'current'
|
||||
choices: choices
|
||||
|
||||
offerToDisableAutomaticUpdates = (application, commit) ->
|
||||
offerToDisableAutomaticUpdates = (application, commit, pinDevice) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
form = require('resin-cli-form')
|
||||
|
||||
if commit == LATEST or not application.should_track_latest_release
|
||||
if isCurrent(commit) or not application.should_track_latest_release or pinDevice
|
||||
return Promise.resolve()
|
||||
message = '''
|
||||
|
||||
This application is set to automatically update all devices to the latest available version.
|
||||
This application is set to automatically update all devices to the current version.
|
||||
This might be unexpected behaviour: with this enabled, the preloaded device will still
|
||||
download and install the latest build once it is online.
|
||||
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.
|
||||
'''
|
||||
form.ask
|
||||
message: message,
|
||||
@ -94,7 +125,7 @@ offerToDisableAutomaticUpdates = (application, commit) ->
|
||||
.then (update) ->
|
||||
if not update
|
||||
return
|
||||
resin.pine.patch
|
||||
balena.pine.patch
|
||||
resource: 'application'
|
||||
id: application.id
|
||||
body:
|
||||
@ -102,19 +133,19 @@ offerToDisableAutomaticUpdates = (application, commit) ->
|
||||
|
||||
module.exports =
|
||||
signature: 'preload <image>'
|
||||
description: '(beta) preload an app on a disk image (or Edison zip archive)'
|
||||
description: 'preload an app on a disk image (or Edison zip archive)'
|
||||
help: '''
|
||||
Warning: "resin preload" requires Docker to be correctly installed in
|
||||
Warning: "balena preload" requires Docker to be correctly installed in
|
||||
your shell environment. For more information (including Windows support)
|
||||
please check the README here: https://github.com/resin-io/resin-cli .
|
||||
please check the README here: https://github.com/balena-io/balena-cli .
|
||||
|
||||
Use this command to preload an application to a local disk image (or
|
||||
Edison zip archive) with a built commit from Resin.io.
|
||||
This can be used with cloud builds, or images deployed with resin deploy.
|
||||
Edison zip archive) with a built release from balena.
|
||||
|
||||
Examples:
|
||||
$ resin preload resin.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image some-image.png
|
||||
$ resin preload resin.img
|
||||
|
||||
$ balena preload balena.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image image.png
|
||||
$ balena preload balena.img
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
@ -129,8 +160,9 @@ module.exports =
|
||||
signature: 'commit'
|
||||
parameter: 'hash'
|
||||
description: '''
|
||||
a specific application commit to preload, use "latest" to specify the latest commit
|
||||
(ignored if no appId is given)
|
||||
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 the CLI to set the commit field on the application.
|
||||
'''
|
||||
alias: 'c'
|
||||
}
|
||||
@ -141,22 +173,25 @@ module.exports =
|
||||
alias: 's'
|
||||
}
|
||||
{
|
||||
signature: 'dont-check-device-type'
|
||||
signature: 'dont-check-arch'
|
||||
boolean: true
|
||||
description: 'Disables check for matching device types in image and application'
|
||||
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'
|
||||
}
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
_ = require('lodash')
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
streamToPromise = require('stream-to-promise')
|
||||
form = require('resin-cli-form')
|
||||
preload = require('resin-preload')
|
||||
errors = require('resin-errors')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
preload = require('balena-preload')
|
||||
visuals = require('resin-cli-visuals')
|
||||
nodeCleanup = require('node-cleanup')
|
||||
{ expectedError } = require('../utils/patterns')
|
||||
{ exitWithExpectedError } = require('../utils/patterns')
|
||||
|
||||
progressBars = {}
|
||||
|
||||
@ -185,21 +220,28 @@ module.exports =
|
||||
options.splashImage = options['splash-image']
|
||||
delete options['splash-image']
|
||||
|
||||
if options['dont-check-device-type'] and not options.appId
|
||||
expectedError('You need to specify an app id if you disable the device type check.')
|
||||
options.dontCheckArch = options['dont-check-arch'] || false
|
||||
delete options['dont-check-arch']
|
||||
if options.dontCheckArch and not 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']
|
||||
|
||||
# Get a configured dockerode instance
|
||||
dockerUtils.getDocker(options)
|
||||
.then (docker) ->
|
||||
|
||||
preloader = new preload.Preloader(
|
||||
resin,
|
||||
docker,
|
||||
options.appId,
|
||||
options.commit,
|
||||
options.image,
|
||||
options.splashImage,
|
||||
options.proxy,
|
||||
balena
|
||||
docker
|
||||
options.appId
|
||||
options.commit
|
||||
options.image
|
||||
options.splashImage
|
||||
options.proxy
|
||||
options.dontCheckArch
|
||||
options.pinDevice
|
||||
)
|
||||
|
||||
gotSignal = false
|
||||
@ -223,51 +265,39 @@ module.exports =
|
||||
return new Promise (resolve, reject) ->
|
||||
preloader.on('error', reject)
|
||||
|
||||
preloader.build()
|
||||
preloader.prepare()
|
||||
.then ->
|
||||
preloader.prepare()
|
||||
.then ->
|
||||
preloader.getDeviceTypeAndPreloadedBuilds()
|
||||
.then (info) ->
|
||||
# If no appId was provided, show a list of matching apps
|
||||
Promise.try ->
|
||||
if options.appId
|
||||
return preloader.fetchApplication()
|
||||
.catch(errors.ResinApplicationNotFound, expectedError)
|
||||
selectApplication(info.device_type)
|
||||
.then (application) ->
|
||||
preloader.setApplication(application)
|
||||
# Check that the app device type and the image device type match
|
||||
if not options['dont-check-device-type'] and info.device_type != application.device_type
|
||||
expectedError(
|
||||
"Image device type (#{info.device_type}) and application device type (#{application.device_type}) do not match"
|
||||
)
|
||||
if not preloader.appId
|
||||
selectApplication(preloader.config.deviceType)
|
||||
.then (application) ->
|
||||
preloader.setApplication(application)
|
||||
.then ->
|
||||
# Use the commit given as --commit or show an interactive commit selection menu
|
||||
Promise.try ->
|
||||
if options.commit
|
||||
if isCurrent(options.commit) and preloader.application.commit
|
||||
# handle `--commit current` (and its `--commit latest` synonym)
|
||||
return 'latest'
|
||||
release = _.find preloader.application.owns__release, (release) ->
|
||||
release.commit.startsWith(options.commit)
|
||||
if not release
|
||||
exitWithExpectedError('There is no release matching this commit')
|
||||
return release.commit
|
||||
selectApplicationCommit(preloader.application.owns__release)
|
||||
.then (commit) ->
|
||||
if isCurrent(commit)
|
||||
preloader.commit = preloader.application.commit
|
||||
else
|
||||
preloader.commit = commit
|
||||
|
||||
# Use the commit given as --commit or show an interactive commit selection menu
|
||||
Promise.try ->
|
||||
if options.commit
|
||||
if options.commit == LATEST and application.commit
|
||||
# handle `--commit latest`
|
||||
return LATEST
|
||||
else if not _.find(application.build, commit_hash: options.commit)
|
||||
expectedError('There is no build matching this commit')
|
||||
return options.commit
|
||||
selectApplicationCommit(application.build)
|
||||
.then (commit) ->
|
||||
if commit == LATEST
|
||||
preloader.commit = application.commit
|
||||
else
|
||||
preloader.commit = commit
|
||||
|
||||
# Propose to disable automatic app updates if the commit is not the latest
|
||||
offerToDisableAutomaticUpdates(application, commit)
|
||||
.then ->
|
||||
builds = info.preloaded_builds.map (build) ->
|
||||
build.slice(-preload.BUILD_HASH_LENGTH)
|
||||
if preloader.commit in builds
|
||||
throw new preload.errors.ResinError('This build is already preloaded in this image.')
|
||||
# All options are ready: preload the image.
|
||||
preloader.preload()
|
||||
.catch(preload.errors.ResinError, expectedError)
|
||||
# Propose to disable automatic app updates if the commit is not the current release
|
||||
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)
|
||||
|
347
lib/actions/push.ts
Normal file
347
lib/actions/push.ts
Normal file
@ -0,0 +1,347 @@
|
||||
/*
|
||||
Copyright 2016-2018 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 { BalenaSDK } from 'balena-sdk';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
import { registrySecretsHelp } from '../utils/messages';
|
||||
import {
|
||||
validateApplicationName,
|
||||
validateDotLocalUrl,
|
||||
validateIPAddress,
|
||||
} from '../utils/validation';
|
||||
|
||||
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 { exitWithExpectedError, 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) {
|
||||
exitWithExpectedError(
|
||||
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;
|
||||
'registry-secrets': string;
|
||||
live: boolean;
|
||||
detached: boolean;
|
||||
service: string;
|
||||
system: 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.
|
||||
|
||||
It is also possible to run a push to a local mode device in live mode.
|
||||
This will watch for changes in the source directory and perform an
|
||||
in-place build in the running containers [BETA].
|
||||
|
||||
${registrySecretsHelp.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 23c73a1.local --system
|
||||
$ balena push 23c73a1.local --system --service my-service
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
signature: 'source',
|
||||
alias: 's',
|
||||
description:
|
||||
'The source that should be sent to the balena builder to be built (defaults to the current directory)',
|
||||
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: "Don't use cache when building this project",
|
||||
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`,
|
||||
},
|
||||
{
|
||||
signature: 'live',
|
||||
alias: 'l',
|
||||
boolean: true,
|
||||
description: stripIndent`
|
||||
Note this feature is in beta.
|
||||
|
||||
Start a live session with the containers pushed to a local mode device.
|
||||
The project source folder is watched for filesystem events, and changes
|
||||
to files and folders are automatically synchronized to the running
|
||||
containers. The synchronisation 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.`,
|
||||
},
|
||||
{
|
||||
signature: 'detached',
|
||||
alias: 'd',
|
||||
description: `Don't tail application logs when pushing to a local mode device`,
|
||||
boolean: true,
|
||||
},
|
||||
{
|
||||
signature: 'service',
|
||||
description: stripIndent`
|
||||
Only show logs from a single service. This can be used in combination with --system.
|
||||
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,
|
||||
},
|
||||
],
|
||||
async action(params, options, done) {
|
||||
const sdk = (await import('balena-sdk')).fromSharedOptions();
|
||||
const Bluebird = await import('bluebird');
|
||||
const remote = await import('../utils/remote-build');
|
||||
const deviceDeploy = await import('../utils/device/deploy');
|
||||
const { exitIfNotLoggedIn, exitWithExpectedError } = await import(
|
||||
'../utils/patterns'
|
||||
);
|
||||
const { validateSpecifiedDockerfile, parseRegistrySecrets } = await import(
|
||||
'../utils/compose_ts'
|
||||
);
|
||||
const { BuildError } = await import('../utils/device/errors');
|
||||
|
||||
const appOrDevice: string | null =
|
||||
params.applicationOrDevice_raw || params.applicationOrDevice;
|
||||
if (appOrDevice == null) {
|
||||
exitWithExpectedError('You must specify an application or a device');
|
||||
}
|
||||
|
||||
const source = options.source || '.';
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[debug] Using ${source} as build source`);
|
||||
}
|
||||
|
||||
const dockerfilePath = validateSpecifiedDockerfile(
|
||||
source,
|
||||
options.dockerfile,
|
||||
);
|
||||
|
||||
const registrySecrets = options['registry-secrets']
|
||||
? await parseRegistrySecrets(options['registry-secrets'])
|
||||
: {};
|
||||
|
||||
const buildTarget = getBuildTarget(appOrDevice);
|
||||
switch (buildTarget) {
|
||||
case BuildTarget.Cloud:
|
||||
// Ensure that the live argument has not been passed to a cloud build
|
||||
if (options.live) {
|
||||
exitWithExpectedError(
|
||||
'The --live flag is only valid when pushing to a local mode device',
|
||||
);
|
||||
}
|
||||
if (options.detached) {
|
||||
exitWithExpectedError(
|
||||
`The --detached flag is only valid when pushing to a local mode device.`,
|
||||
);
|
||||
}
|
||||
if (options.service) {
|
||||
exitWithExpectedError(
|
||||
'The --service flag is only valid when pushing to a local mode device.',
|
||||
);
|
||||
}
|
||||
if (options.system) {
|
||||
exitWithExpectedError(
|
||||
'The --system flag is only valid when pushing to a local mode device.',
|
||||
);
|
||||
}
|
||||
|
||||
const app = appOrDevice;
|
||||
await exitIfNotLoggedIn();
|
||||
await Bluebird.join(
|
||||
sdk.auth.getToken(),
|
||||
sdk.settings.get('balenaUrl'),
|
||||
getAppOwner(sdk, app),
|
||||
async (token, baseUrl, owner) => {
|
||||
const opts = {
|
||||
dockerfilePath,
|
||||
emulated: options.emulated,
|
||||
nocache: options.nocache,
|
||||
registrySecrets,
|
||||
};
|
||||
const args = {
|
||||
app,
|
||||
owner,
|
||||
source,
|
||||
auth: token,
|
||||
baseUrl,
|
||||
sdk,
|
||||
opts,
|
||||
};
|
||||
|
||||
return await remote.startRemoteBuild(args);
|
||||
},
|
||||
).nodeify(done);
|
||||
break;
|
||||
case BuildTarget.Device:
|
||||
const device = appOrDevice;
|
||||
// TODO: Support passing a different port
|
||||
await Bluebird.resolve(
|
||||
deviceDeploy.deployToDevice({
|
||||
source,
|
||||
deviceHost: device,
|
||||
dockerfilePath,
|
||||
registrySecrets,
|
||||
nocache: options.nocache || false,
|
||||
live: options.live || false,
|
||||
detached: options.detached || false,
|
||||
service: options.service,
|
||||
system: options.system || false,
|
||||
}),
|
||||
)
|
||||
.catch(BuildError, e => {
|
||||
exitWithExpectedError(e.toString());
|
||||
})
|
||||
.nodeify(done);
|
||||
break;
|
||||
default:
|
||||
exitWithExpectedError(
|
||||
stripIndent`
|
||||
Build target not recognised. 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.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
/*
|
||||
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.
|
||||
@ -12,23 +12,28 @@ 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.
|
||||
###
|
||||
*/
|
||||
|
||||
exports.list =
|
||||
signature: 'settings'
|
||||
description: 'print current settings'
|
||||
help: '''
|
||||
Use this command to display detected settings
|
||||
import { CommandDefinition } from 'capitano';
|
||||
|
||||
Examples:
|
||||
export const list: CommandDefinition = {
|
||||
signature: 'settings',
|
||||
description: 'print current settings',
|
||||
help: `\
|
||||
Use this command to display detected settings
|
||||
|
||||
$ resin settings
|
||||
'''
|
||||
action: (params, options, done) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
prettyjson = require('prettyjson')
|
||||
Examples:
|
||||
|
||||
resin.settings.getAll()
|
||||
$ balena settings\
|
||||
`,
|
||||
async action(_params, _options, done) {
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const prettyjson = await import('prettyjson');
|
||||
|
||||
return balena.settings
|
||||
.getAll()
|
||||
.then(prettyjson.render)
|
||||
.then(console.log)
|
||||
.nodeify(done)
|
||||
.nodeify(done);
|
||||
},
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -14,23 +14,28 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
commandOptions = require('./command-options')
|
||||
{ normalizeUuidProp } = require('../utils/normalization')
|
||||
|
||||
module.exports =
|
||||
signature: 'ssh [uuid]'
|
||||
description: '(beta) get a shell into the running app container of a device'
|
||||
description: 'get a shell into the running app container of a device'
|
||||
help: '''
|
||||
Warning: 'resin ssh' requires an openssh-compatible client to be correctly
|
||||
Warning: 'balena ssh' requires an openssh-compatible client to be correctly
|
||||
installed in your shell environment. For more information (including Windows
|
||||
support) please check the README here: https://github.com/resin-io/resin-cli
|
||||
support) please check the README here: https://github.com/balena-io/balena-cli
|
||||
|
||||
Use this command to get a shell into the running application container of
|
||||
your device.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin ssh MyApp
|
||||
$ resin ssh 7cf02a6
|
||||
$ resin ssh 7cf02a6 --port 8080
|
||||
$ resin ssh 7cf02a6 -v
|
||||
$ balena ssh MyApp
|
||||
$ balena ssh 7cf02a6
|
||||
$ balena ssh 7cf02a6 --port 8080
|
||||
$ balena ssh 7cf02a6 -v
|
||||
$ balena ssh 7cf02a6 -s
|
||||
$ balena ssh 7cf02a6 --noninteractive
|
||||
'''
|
||||
permission: 'user'
|
||||
primary: true
|
||||
@ -44,16 +49,21 @@ module.exports =
|
||||
boolean: true
|
||||
description: 'increase verbosity'
|
||||
alias: 'v'
|
||||
,
|
||||
commandOptions.hostOSAccess,
|
||||
signature: 'noproxy'
|
||||
boolean: true
|
||||
description: "don't use the proxy configuration for this connection.
|
||||
Only makes sense if you've configured proxy globally."
|
||||
,
|
||||
signature: 'noninteractive'
|
||||
boolean: true
|
||||
description: 'run command non-interactively, do not automatically suggest devices to connect to if UUID not found'
|
||||
]
|
||||
action: (params, options, done) ->
|
||||
normalizeUuidProp(params)
|
||||
child_process = require('child_process')
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
_ = require('lodash')
|
||||
bash = require('bash')
|
||||
hasbin = require('hasbin')
|
||||
@ -73,8 +83,8 @@ module.exports =
|
||||
if not hasTunnelBin
|
||||
console.warn('''
|
||||
Proxy is enabled but the `proxytunnel` binary cannot be found.
|
||||
Please install it if you want to route the `resin ssh` requests through the proxy.
|
||||
Alternatively you can pass `--noproxy` param to the `resin ssh` command to ignore the proxy config
|
||||
Please install it if you want to route the `balena ssh` requests through the proxy.
|
||||
Alternatively you can pass `--noproxy` param to the `balena ssh` command to ignore the proxy config
|
||||
for the `ssh` requests.
|
||||
|
||||
Attemmpting the unproxied request for now.
|
||||
@ -95,34 +105,43 @@ module.exports =
|
||||
|
||||
Promise.try ->
|
||||
return false if not params.uuid
|
||||
return resin.models.device.has(params.uuid)
|
||||
return balena.models.device.has(params.uuid)
|
||||
.then (uuidExists) ->
|
||||
return params.uuid if uuidExists
|
||||
if options.noninteractive
|
||||
console.error("Could not find device: #{params.uuid}")
|
||||
process.exit(1)
|
||||
return patterns.inferOrSelectDevice()
|
||||
.then (uuid) ->
|
||||
console.info("Connecting to: #{uuid}")
|
||||
resin.models.device.get(uuid)
|
||||
balena.models.device.get(uuid)
|
||||
.then (device) ->
|
||||
throw new Error('Device is not online') if not device.is_online
|
||||
patterns.exitWithExpectedError('Device is not online') if not device.is_online
|
||||
|
||||
Promise.props
|
||||
username: resin.auth.whoami()
|
||||
username: balena.auth.whoami()
|
||||
uuid: device.uuid
|
||||
# get full uuid
|
||||
containerId: resin.models.device.getApplicationInfo(device.uuid).get('containerId')
|
||||
proxyUrl: resin.settings.get('proxyUrl')
|
||||
containerId: if options.host then '' else balena.models.device.getApplicationInfo(device.uuid).get('containerId')
|
||||
proxyUrl: balena.settings.get('proxyUrl')
|
||||
|
||||
hasTunnelBin: if useProxy then hasbin('proxytunnel') else null
|
||||
.then ({ username, uuid, containerId, proxyUrl, hasTunnelBin }) ->
|
||||
throw new Error('Did not find running application container') if not containerId?
|
||||
Promise.try ->
|
||||
sshProxyCommand = getSshProxyCommand(hasTunnelBin)
|
||||
|
||||
if options.host
|
||||
accessCommand = "host #{uuid}"
|
||||
else
|
||||
accessCommand = "enter #{uuid} #{containerId}"
|
||||
|
||||
command = "ssh #{verbose} -t \
|
||||
-o LogLevel=ERROR \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
#{sshProxyCommand} \
|
||||
-p #{options.port} #{username}@ssh.#{proxyUrl} enter #{uuid} #{containerId}"
|
||||
-p #{options.port} #{username}@ssh.#{proxyUrl} #{accessCommand}"
|
||||
|
||||
subShellCommand = getSubShellCommand(command)
|
||||
child_process.spawn subShellCommand.program, subShellCommand.args,
|
||||
|
44
lib/actions/sync.ts
Normal file
44
lib/actions/sync.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2016-2019 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 BalenaSync from 'balena-sync';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
export = deprecateSyncCmd(BalenaSync.capitano('balena-cli'));
|
||||
|
||||
const deprecationMsg = stripIndent`\
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
Deprecation notice: please note that \`balena sync\` is deprecated and will
|
||||
be removed in a future release of the CLI. We are working on an exciting
|
||||
replacement that will be released soon!
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
`;
|
||||
|
||||
function deprecateSyncCmd(syncCmd: CommandDefinition): CommandDefinition {
|
||||
syncCmd.primary = false;
|
||||
syncCmd.description = syncCmd.description.replace(
|
||||
'(beta)',
|
||||
'[deprecated: see "help sync"]',
|
||||
);
|
||||
syncCmd.help = deprecationMsg + '\n\n' + syncCmd.help;
|
||||
const originalAction = syncCmd.action;
|
||||
syncCmd.action = (params, options, done): void => {
|
||||
console.log(deprecationMsg);
|
||||
originalAction(params, options, done);
|
||||
};
|
||||
return syncCmd;
|
||||
}
|
277
lib/actions/tags.ts
Normal file
277
lib/actions/tags.ts
Normal file
@ -0,0 +1,277 @@
|
||||
/*
|
||||
Copyright 2016-2018 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 { ApplicationTag, DeviceTag, ReleaseTag } from 'balena-sdk';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import { normalizeUuidProp } from '../utils/normalization';
|
||||
import * as commandOptions from './command-options';
|
||||
|
||||
export const list: CommandDefinition<
|
||||
{},
|
||||
{
|
||||
application?: string;
|
||||
device?: string;
|
||||
release?: number;
|
||||
}
|
||||
> = {
|
||||
signature: 'tags',
|
||||
description: 'list all resource tags',
|
||||
help: stripIndent`
|
||||
Use this command to list all tags for
|
||||
a particular application, device or release.
|
||||
|
||||
This command lists all application/device/release tags.
|
||||
|
||||
Example:
|
||||
|
||||
$ balena tags --application MyApp
|
||||
$ balena tags --device 7cf02a6
|
||||
$ balena tags --release 1234
|
||||
`,
|
||||
options: [
|
||||
commandOptions.optionalApplication,
|
||||
commandOptions.optionalDevice,
|
||||
commandOptions.optionalRelease,
|
||||
],
|
||||
permission: 'user',
|
||||
async action(_params, options, done) {
|
||||
normalizeUuidProp(options, 'device');
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const visuals = await import('resin-cli-visuals');
|
||||
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
return Bluebird.try<ApplicationTag[] | DeviceTag[] | ReleaseTag[]>(() => {
|
||||
const wrongParametersError = stripIndent`
|
||||
To list resource tags, you must provide exactly one of:
|
||||
|
||||
* An application, with --application <appname>
|
||||
* A device, with --device <uuid>
|
||||
* A release, with --release <id>
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ balena help tags
|
||||
`;
|
||||
|
||||
if (
|
||||
_.filter([options.application, options.device, options.release])
|
||||
.length !== 1
|
||||
) {
|
||||
return exitWithExpectedError(wrongParametersError);
|
||||
}
|
||||
|
||||
if (options.application) {
|
||||
return balena.models.application.tags.getAllByApplication(
|
||||
options.application,
|
||||
);
|
||||
}
|
||||
if (options.device) {
|
||||
return balena.models.device.tags.getAllByDevice(options.device);
|
||||
}
|
||||
if (options.release) {
|
||||
return balena.models.release.tags.getAllByRelease(options.release);
|
||||
}
|
||||
|
||||
// return never, so that TS typings are happy
|
||||
return exitWithExpectedError(wrongParametersError);
|
||||
})
|
||||
.tap(function(environmentVariables) {
|
||||
if (_.isEmpty(environmentVariables)) {
|
||||
exitWithExpectedError('No tags found');
|
||||
}
|
||||
|
||||
console.log(
|
||||
visuals.table.horizontal(environmentVariables, [
|
||||
'id',
|
||||
'tag_key',
|
||||
'value',
|
||||
]),
|
||||
);
|
||||
})
|
||||
.nodeify(done);
|
||||
},
|
||||
};
|
||||
|
||||
export const set: CommandDefinition<
|
||||
{
|
||||
tagKey: string;
|
||||
value?: string;
|
||||
},
|
||||
{
|
||||
application?: string;
|
||||
device?: string;
|
||||
release?: number;
|
||||
}
|
||||
> = {
|
||||
signature: 'tag set <tagKey> [value]',
|
||||
description: 'set a resource tag',
|
||||
help: stripIndent`
|
||||
Use this command to set a tag to an application, device or release.
|
||||
|
||||
You can optionally provide a value to be associated with the created
|
||||
tag, as an extra argument after the tag key. When the value isn't
|
||||
provided, a tag with an empty value is created.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena tag set mySimpleTag --application MyApp
|
||||
$ balena tag set myCompositeTag myTagValue --application MyApp
|
||||
$ balena tag set myCompositeTag myTagValue --device 7cf02a6
|
||||
$ balena tag set myCompositeTag myTagValue --release 1234
|
||||
$ balena tag set myCompositeTag "my tag value with whitespaces" --release 1234
|
||||
`,
|
||||
options: [
|
||||
commandOptions.optionalApplication,
|
||||
commandOptions.optionalDevice,
|
||||
commandOptions.optionalRelease,
|
||||
],
|
||||
permission: 'user',
|
||||
async action(params, options, done) {
|
||||
normalizeUuidProp(options, 'device');
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
return Bluebird.try(() => {
|
||||
if (_.isEmpty(params.tagKey)) {
|
||||
return exitWithExpectedError('No tag key was provided');
|
||||
}
|
||||
|
||||
if (
|
||||
_.filter([options.application, options.device, options.release])
|
||||
.length !== 1
|
||||
) {
|
||||
return exitWithExpectedError(stripIndent`
|
||||
To set a resource tag, you must provide exactly one of:
|
||||
|
||||
* An application, with --application <appname>
|
||||
* A device, with --device <uuid>
|
||||
* A release, with --release <id>
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ balena help tag set
|
||||
`);
|
||||
}
|
||||
|
||||
if (params.value == null) {
|
||||
params.value = '';
|
||||
}
|
||||
|
||||
if (options.application) {
|
||||
return balena.models.application.tags.set(
|
||||
options.application,
|
||||
params.tagKey,
|
||||
params.value,
|
||||
);
|
||||
}
|
||||
if (options.device) {
|
||||
return balena.models.device.tags.set(
|
||||
options.device,
|
||||
params.tagKey,
|
||||
params.value,
|
||||
);
|
||||
}
|
||||
if (options.release) {
|
||||
return balena.models.release.tags.set(
|
||||
options.release,
|
||||
params.tagKey,
|
||||
params.value,
|
||||
);
|
||||
}
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
||||
|
||||
export const remove: CommandDefinition<
|
||||
{
|
||||
tagKey: string;
|
||||
},
|
||||
{
|
||||
application?: string;
|
||||
device?: string;
|
||||
release?: number;
|
||||
}
|
||||
> = {
|
||||
signature: 'tag rm <tagKey>',
|
||||
description: 'remove a resource tag',
|
||||
help: stripIndent`
|
||||
Use this command to remove a tag from an application, device or release.
|
||||
|
||||
Examples:
|
||||
|
||||
$ balena tag rm myTagKey --application MyApp
|
||||
$ balena tag rm myTagKey --device 7cf02a6
|
||||
$ balena tag rm myTagKey --release 1234
|
||||
`,
|
||||
options: [
|
||||
commandOptions.optionalApplication,
|
||||
commandOptions.optionalDevice,
|
||||
commandOptions.optionalRelease,
|
||||
],
|
||||
permission: 'user',
|
||||
async action(params, options, done) {
|
||||
const Bluebird = await import('bluebird');
|
||||
const _ = await import('lodash');
|
||||
const balena = (await import('balena-sdk')).fromSharedOptions();
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
|
||||
return Bluebird.try(() => {
|
||||
if (_.isEmpty(params.tagKey)) {
|
||||
return exitWithExpectedError('No tag key was provided');
|
||||
}
|
||||
|
||||
if (
|
||||
_.filter([options.application, options.device, options.release])
|
||||
.length !== 1
|
||||
) {
|
||||
return exitWithExpectedError(stripIndent`
|
||||
To remove a resource tag, you must provide exactly one of:
|
||||
|
||||
* An application, with --application <appname>
|
||||
* A device, with --device <uuid>
|
||||
* A release, with --release <id>
|
||||
|
||||
See the help page for examples:
|
||||
|
||||
$ balena help tag rm
|
||||
`);
|
||||
}
|
||||
|
||||
if (options.application) {
|
||||
return balena.models.application.tags.remove(
|
||||
options.application,
|
||||
params.tagKey,
|
||||
);
|
||||
}
|
||||
if (options.device) {
|
||||
return balena.models.device.tags.remove(options.device, params.tagKey);
|
||||
}
|
||||
if (options.release) {
|
||||
return balena.models.release.tags.remove(
|
||||
options.release,
|
||||
params.tagKey,
|
||||
);
|
||||
}
|
||||
}).nodeify(done);
|
||||
},
|
||||
};
|
245
lib/actions/tunnel.ts
Normal file
245
lib/actions/tunnel.ts
Normal file
@ -0,0 +1,245 @@
|
||||
/*
|
||||
Copyright 2019 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 Bluebird from 'bluebird';
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import * as _ from 'lodash';
|
||||
import { createServer, Server, Socket } from 'net';
|
||||
import { isArray } from 'util';
|
||||
|
||||
import { inferOrSelectDevice } from '../utils/patterns';
|
||||
import { tunnelConnectionToDevice } from '../utils/tunnel';
|
||||
|
||||
interface Args {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
port: string | string[];
|
||||
}
|
||||
|
||||
class DeviceIsOfflineError extends Error {
|
||||
public uuid: string;
|
||||
constructor(uuid: string) {
|
||||
super(`Device '${uuid}' is offline`);
|
||||
this.uuid = uuid;
|
||||
}
|
||||
}
|
||||
class InvalidPortMappingError extends Error {
|
||||
constructor(mapping: string) {
|
||||
super(`'${mapping}' is not a valid port mapping.`);
|
||||
}
|
||||
}
|
||||
|
||||
class NoPortsDefinedError extends Error {
|
||||
constructor() {
|
||||
super('No ports have been provided.');
|
||||
}
|
||||
}
|
||||
|
||||
const isValidPort = (port: number) => {
|
||||
const MAX_PORT_VALUE = Math.pow(2, 16) - 1;
|
||||
return port > 0 && port <= MAX_PORT_VALUE;
|
||||
};
|
||||
|
||||
export const tunnel: CommandDefinition<Args, Options> = {
|
||||
signature: 'tunnel <uuid>',
|
||||
description: 'Tunnel local ports to your balenaOS device',
|
||||
help: stripIndent`
|
||||
Use this command to open local ports which tunnel to listening ports on your balenaOS device.
|
||||
|
||||
For example, you could open port 8080 on your local machine to connect to your managed balenaOS
|
||||
device running a web server listening on port 3000.
|
||||
|
||||
You can tunnel multiple ports at any given time.
|
||||
|
||||
Examples:
|
||||
|
||||
# map remote port 22222 to localhost:22222
|
||||
$ balena tunnel abcde12345 -p 22222
|
||||
|
||||
# map remote port 22222 to localhost:222
|
||||
$ balena tunnel abcde12345 -p 22222:222
|
||||
|
||||
# map remote port 22222 to any address on your host machine, port 22222
|
||||
$ balena tunnel abcde12345 -p 22222:0.0.0.0
|
||||
|
||||
# map remote port 22222 to any address on your host machine, port 222
|
||||
$ balena tunnel abcde12345 -p 22222:0.0.0.0:222
|
||||
|
||||
# multiple port tunnels can be specified at any one time
|
||||
$ balena tunnel abcde12345 -p 8080:3000 -p 8081:9000
|
||||
`,
|
||||
options: [
|
||||
{
|
||||
signature: 'port',
|
||||
parameter: 'port',
|
||||
alias: 'p',
|
||||
description: 'The mapping of remote to local ports.',
|
||||
},
|
||||
],
|
||||
|
||||
primary: true,
|
||||
|
||||
async action(params, options, done) {
|
||||
const Logger = await import('../utils/logger');
|
||||
const logger = new Logger();
|
||||
const balena = await import('balena-sdk');
|
||||
const sdk = balena.fromSharedOptions();
|
||||
|
||||
const logConnection = (
|
||||
fromHost: string,
|
||||
fromPort: number,
|
||||
localAddress: string,
|
||||
localPort: number,
|
||||
deviceAddress: string,
|
||||
devicePort: number,
|
||||
err?: Error,
|
||||
) => {
|
||||
const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`;
|
||||
|
||||
if (err) {
|
||||
logger.logError(`${logMessage} :: ${err.message}`);
|
||||
} else {
|
||||
logger.logLogs(logMessage);
|
||||
}
|
||||
};
|
||||
|
||||
logger.logInfo(`Tunnel to ${params.uuid}`);
|
||||
|
||||
if (options.port === undefined) {
|
||||
throw new NoPortsDefinedError();
|
||||
}
|
||||
|
||||
const ports =
|
||||
typeof options.port !== 'string' && isArray(options.port)
|
||||
? (options.port as string[])
|
||||
: [options.port as string];
|
||||
|
||||
return inferOrSelectDevice(params.uuid)
|
||||
.then(sdk.models.device.get)
|
||||
.then(device => {
|
||||
if (!device.is_online) {
|
||||
throw new DeviceIsOfflineError(device.uuid);
|
||||
}
|
||||
|
||||
const localListeners = _.chain(ports)
|
||||
.map(mapping => {
|
||||
const regexResult = /^([0-9]+)(?:$|\:(?:([\w\:\.]+)\:|)([0-9]+))$/.exec(
|
||||
mapping,
|
||||
);
|
||||
|
||||
if (regexResult === null) {
|
||||
throw new InvalidPortMappingError(mapping);
|
||||
}
|
||||
|
||||
// grab the groups
|
||||
// tslint:disable-next-line:prefer-const
|
||||
let [, remotePort, localAddress, localPort] = regexResult;
|
||||
|
||||
if (
|
||||
!isValidPort(parseInt(localPort, undefined)) ||
|
||||
!isValidPort(parseInt(remotePort, undefined))
|
||||
) {
|
||||
throw new InvalidPortMappingError(mapping);
|
||||
}
|
||||
|
||||
// default bind to localAddress
|
||||
if (localAddress == null) {
|
||||
localAddress = 'localhost';
|
||||
}
|
||||
|
||||
// default use same port number locally as remote
|
||||
if (localPort == null) {
|
||||
localPort = remotePort;
|
||||
}
|
||||
|
||||
return {
|
||||
localPort: parseInt(localPort, undefined),
|
||||
localAddress,
|
||||
remotePort: parseInt(remotePort, undefined),
|
||||
};
|
||||
})
|
||||
.map(({ localPort, localAddress, remotePort }) => {
|
||||
return tunnelConnectionToDevice(device.uuid, remotePort, sdk)
|
||||
.then(handler =>
|
||||
createServer((client: Socket) => {
|
||||
return handler(client)
|
||||
.then(() => {
|
||||
logConnection(
|
||||
client.remoteAddress,
|
||||
client.remotePort,
|
||||
client.localAddress,
|
||||
client.localPort,
|
||||
device.vpn_address || '',
|
||||
remotePort,
|
||||
);
|
||||
})
|
||||
.catch(err =>
|
||||
logConnection(
|
||||
client.remoteAddress,
|
||||
client.remotePort,
|
||||
client.localAddress,
|
||||
client.localPort,
|
||||
device.vpn_address || '',
|
||||
remotePort,
|
||||
err,
|
||||
),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.then(
|
||||
server =>
|
||||
new Bluebird.Promise<Server>((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(localPort, localAddress, () => {
|
||||
resolve(server);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
logger.logInfo(
|
||||
` - tunnelling ${localAddress}:${localPort} to ${
|
||||
device.uuid
|
||||
}:${remotePort}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.logWarn(
|
||||
` - not tunnelling ${localAddress}:${localPort} to ${
|
||||
device.uuid
|
||||
}:${remotePort}, failed ${JSON.stringify(err.message)}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
});
|
||||
})
|
||||
.value();
|
||||
|
||||
return Bluebird.all(localListeners);
|
||||
})
|
||||
.then(results => {
|
||||
if (!results.includes(true)) {
|
||||
throw new Error('No ports are valid for tunnelling');
|
||||
}
|
||||
|
||||
logger.logInfo('Waiting for connections...');
|
||||
})
|
||||
.nodeify(done);
|
||||
},
|
||||
};
|
@ -1,56 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
_ = require('lodash')
|
||||
|
||||
exports.availableDrives =
|
||||
# TODO: dedupe with https://github.com/resin-io-modules/resin-cli-visuals/blob/master/lib/widgets/drive/index.coffee
|
||||
signature: 'util available-drives'
|
||||
description: 'list available drives'
|
||||
help: """
|
||||
Use this command to list your machine's drives usable for writing the OS image to.
|
||||
Skips the system drives.
|
||||
"""
|
||||
action: ->
|
||||
Promise = require('bluebird')
|
||||
drivelist = require('drivelist')
|
||||
driveListAsync = Promise.promisify(drivelist.list)
|
||||
chalk = require('chalk')
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
formatDrive = (drive) ->
|
||||
size = drive.size / 1000000000
|
||||
return {
|
||||
device: drive.device
|
||||
size: "#{size.toFixed(1)} GB"
|
||||
description: drive.description
|
||||
}
|
||||
|
||||
getDrives = ->
|
||||
driveListAsync().then (drives) ->
|
||||
return _.reject(drives, system: true)
|
||||
|
||||
getDrives()
|
||||
.then (drives) ->
|
||||
if not drives.length
|
||||
console.error("#{chalk.red('x')} No available drives were detected, plug one in!")
|
||||
return
|
||||
|
||||
console.log visuals.table.horizontal drives.map(formatDrive), [
|
||||
'device'
|
||||
'size'
|
||||
'description'
|
||||
]
|
65
lib/actions/util.ts
Normal file
65
lib/actions/util.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { CommandDefinition } from 'capitano';
|
||||
import chalk from 'chalk';
|
||||
import { stripIndent } from 'common-tags';
|
||||
|
||||
export const availableDrives: CommandDefinition<{}, {}> = {
|
||||
signature: 'util available-drives',
|
||||
description: 'list available drives',
|
||||
help: stripIndent`
|
||||
Use this command to list your machine's drives usable for writing the OS image to.
|
||||
Skips the system drives.
|
||||
`,
|
||||
async action() {
|
||||
const sdk = await import('etcher-sdk');
|
||||
const visuals = await import('resin-cli-visuals');
|
||||
|
||||
const adapter = new sdk.scanner.adapters.BlockDeviceAdapter(() => false);
|
||||
const scanner = new sdk.scanner.Scanner([adapter]);
|
||||
await scanner.start();
|
||||
|
||||
function formatDrive(drive: any) {
|
||||
const size = drive.size / 1000000000;
|
||||
return {
|
||||
device: drive.device,
|
||||
size: `${size.toFixed(1)} GB`,
|
||||
description: drive.description,
|
||||
};
|
||||
}
|
||||
|
||||
if (scanner.drives.size === 0) {
|
||||
console.error(
|
||||
`${chalk.red('x')} No available drives were detected, plug one in!`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
visuals.table.horizontal(Array.from(scanner.drives).map(formatDrive), [
|
||||
'device',
|
||||
'size',
|
||||
'description',
|
||||
]),
|
||||
);
|
||||
}
|
||||
scanner.stop();
|
||||
},
|
||||
};
|
@ -1,75 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
exports.wizard =
|
||||
signature: 'quickstart [name]'
|
||||
description: 'getting started with resin.io'
|
||||
help: '''
|
||||
Use this command to run a friendly wizard to get started with resin.io.
|
||||
|
||||
The wizard will guide you through:
|
||||
|
||||
- Create an application.
|
||||
- Initialise an SDCard with the resin.io operating system.
|
||||
- Associate an existing project directory with your resin.io application.
|
||||
- Push your project to your devices.
|
||||
|
||||
Examples:
|
||||
|
||||
$ resin quickstart
|
||||
$ resin quickstart MyApp
|
||||
'''
|
||||
primary: true
|
||||
action: (params, options, done) ->
|
||||
Promise = require('bluebird')
|
||||
capitanoRunAsync = Promise.promisify(require('capitano').run)
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
patterns = require('../utils/patterns')
|
||||
|
||||
resin.auth.isLoggedIn().then (isLoggedIn) ->
|
||||
return if isLoggedIn
|
||||
console.info('Looks like you\'re not logged in yet!')
|
||||
console.info('Lets go through a quick wizard to get you started.\n')
|
||||
return capitanoRunAsync('login')
|
||||
.then ->
|
||||
return if params.name?
|
||||
patterns.selectOrCreateApplication().tap (applicationName) ->
|
||||
resin.models.application.has(applicationName).then (hasApplication) ->
|
||||
return applicationName if hasApplication
|
||||
capitanoRunAsync("app create #{applicationName}")
|
||||
.then (applicationName) ->
|
||||
params.name = applicationName
|
||||
.then ->
|
||||
return capitanoRunAsync("device init --application #{params.name}")
|
||||
.tap(patterns.awaitDevice)
|
||||
.then (uuid) ->
|
||||
return capitanoRunAsync("device #{uuid}")
|
||||
.then ->
|
||||
return resin.models.application.get(params.name)
|
||||
.then (application) ->
|
||||
console.log """
|
||||
Your device is ready to start pushing some code!
|
||||
|
||||
Check our official documentation for more information:
|
||||
|
||||
http://docs.resin.io/#/pages/introduction/introduction.md
|
||||
|
||||
Clone an example or go to an existing application directory and run:
|
||||
|
||||
$ git remote add resin #{application.git_repository}
|
||||
$ git push resin master
|
||||
"""
|
||||
.nodeify(done)
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
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.
|
||||
@ -17,7 +17,8 @@ limitations under the License.
|
||||
Raven = require('raven')
|
||||
Raven.disableConsoleAlerts()
|
||||
Raven.config require('./config').sentryDsn,
|
||||
captureUnhandledRejections: true
|
||||
captureUnhandledRejections: true,
|
||||
autoBreadcrumbs: true,
|
||||
release: require('../package.json').version
|
||||
.install (logged, error) ->
|
||||
console.error(error)
|
||||
@ -40,10 +41,10 @@ if not require('semver').satisfies(process.version, validNodeVersions)
|
||||
|
||||
|
||||
# Doing this before requiring any other modules,
|
||||
# including the 'resin-sdk', to prevent any module from reading the http proxy config
|
||||
# including the 'balena-sdk', to prevent any module from reading the http proxy config
|
||||
# before us
|
||||
globalTunnel = require('global-tunnel-ng')
|
||||
settings = require('resin-settings-client')
|
||||
settings = require('balena-settings-client')
|
||||
try
|
||||
proxy = settings.get('proxy') or null
|
||||
catch
|
||||
@ -53,30 +54,28 @@ catch
|
||||
# If that is not set as well the initialize will do nothing
|
||||
globalTunnel.initialize(proxy)
|
||||
|
||||
# TODO: make this a feature of capitano https://github.com/resin-io/capitano/issues/48
|
||||
# TODO: make this a feature of capitano https://github.com/balena-io/capitano/issues/48
|
||||
global.PROXY_CONFIG = globalTunnel.proxyConfig
|
||||
|
||||
_ = require('lodash')
|
||||
Promise = require('bluebird')
|
||||
capitano = require('capitano')
|
||||
capitanoExecuteAsync = Promise.promisify(capitano.execute)
|
||||
|
||||
# We don't yet use resin-sdk directly everywhere, but we set up shared
|
||||
# We don't yet use balena-sdk directly everywhere, but we set up shared
|
||||
# options correctly so we can do safely in submodules
|
||||
require('resin-sdk').setSharedOptions(
|
||||
BalenaSdk = require('balena-sdk')
|
||||
BalenaSdk.setSharedOptions(
|
||||
apiUrl: settings.get('apiUrl')
|
||||
imageMakerUrl: settings.get('imageMakerUrl')
|
||||
dataDirectory: settings.get('dataDirectory')
|
||||
retries: 2
|
||||
)
|
||||
# Keep using sdk-preconfigured for now, but only temporarily
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
actions = require('./actions')
|
||||
errors = require('./errors')
|
||||
events = require('./events')
|
||||
plugins = require('./utils/plugins')
|
||||
update = require('./utils/update')
|
||||
{ exitIfNotLoggedIn } = require('./utils/patterns')
|
||||
|
||||
# Assign bluebird as the global promise library
|
||||
# stream-to-promise will produce native promises if not
|
||||
@ -85,16 +84,8 @@ update = require('./utils/update')
|
||||
require('any-promise/register/bluebird')
|
||||
|
||||
capitano.permission 'user', (done) ->
|
||||
resin.auth.isLoggedIn().then (isLoggedIn) ->
|
||||
if not isLoggedIn
|
||||
throw new Error '''
|
||||
You have to log in to continue
|
||||
|
||||
Run the following command to go through the login wizard:
|
||||
|
||||
$ resin login
|
||||
'''
|
||||
.nodeify(done)
|
||||
exitIfNotLoggedIn()
|
||||
.then(done, done)
|
||||
|
||||
capitano.command
|
||||
signature: '*'
|
||||
@ -112,14 +103,8 @@ capitano.command(actions.info.version)
|
||||
# ---------- Help Module ----------
|
||||
capitano.command(actions.help.help)
|
||||
|
||||
# ---------- Wizard Module ----------
|
||||
capitano.command(actions.wizard.wizard)
|
||||
|
||||
# ---------- Auth Module ----------
|
||||
capitano.command(actions.auth.login)
|
||||
capitano.command(actions.auth.logout)
|
||||
capitano.command(actions.auth.signup)
|
||||
capitano.command(actions.auth.whoami)
|
||||
# ---------- Api key module ----------
|
||||
capitano.command(actions.apiKey.generate)
|
||||
|
||||
# ---------- App Module ----------
|
||||
capitano.command(actions.app.create)
|
||||
@ -128,6 +113,12 @@ capitano.command(actions.app.remove)
|
||||
capitano.command(actions.app.restart)
|
||||
capitano.command(actions.app.info)
|
||||
|
||||
# ---------- Auth Module ----------
|
||||
capitano.command(actions.auth.login)
|
||||
capitano.command(actions.auth.logout)
|
||||
capitano.command(actions.auth.signup)
|
||||
capitano.command(actions.auth.whoami)
|
||||
|
||||
# ---------- Device Module ----------
|
||||
capitano.command(actions.device.list)
|
||||
capitano.command(actions.device.supported)
|
||||
@ -160,6 +151,11 @@ capitano.command(actions.env.add)
|
||||
capitano.command(actions.env.rename)
|
||||
capitano.command(actions.env.remove)
|
||||
|
||||
# ---------- Tags Module ----------
|
||||
capitano.command(actions.tags.list)
|
||||
capitano.command(actions.tags.set)
|
||||
capitano.command(actions.tags.remove)
|
||||
|
||||
# ---------- OS Module ----------
|
||||
capitano.command(actions.os.versions)
|
||||
capitano.command(actions.os.download)
|
||||
@ -178,18 +174,21 @@ capitano.command(actions.config.generate)
|
||||
capitano.command(actions.settings.list)
|
||||
|
||||
# ---------- Logs Module ----------
|
||||
capitano.command(actions.logs)
|
||||
capitano.command(actions.logs.logs)
|
||||
|
||||
# ---------- Sync Module ----------
|
||||
capitano.command(actions.sync)
|
||||
|
||||
# ---------- Tunnel Module ----------
|
||||
capitano.command(actions.tunnel.tunnel)
|
||||
|
||||
# ---------- Preload Module ----------
|
||||
capitano.command(actions.preload)
|
||||
|
||||
# ---------- SSH Module ----------
|
||||
capitano.command(actions.ssh)
|
||||
|
||||
# ---------- Local ResinOS Module ----------
|
||||
# ---------- Local balenaOS Module ----------
|
||||
capitano.command(actions.local.configure)
|
||||
capitano.command(actions.local.flash)
|
||||
capitano.command(actions.local.logs)
|
||||
@ -203,22 +202,28 @@ capitano.command(actions.util.availableDrives)
|
||||
|
||||
# ---------- Internal utils ----------
|
||||
capitano.command(actions.internal.osInit)
|
||||
capitano.command(actions.internal.scanDevices)
|
||||
capitano.command(actions.internal.sudo)
|
||||
|
||||
#------------ Local build and deploy -------
|
||||
capitano.command(actions.build)
|
||||
capitano.command(actions.deploy)
|
||||
|
||||
#------------ Push/remote builds -------
|
||||
capitano.command(actions.push.push)
|
||||
|
||||
#------------ Join/Leave -------
|
||||
capitano.command(actions.join.join)
|
||||
capitano.command(actions.leave.leave)
|
||||
|
||||
update.notify()
|
||||
|
||||
plugins.register(/^resin-plugin-(.+)$/).then ->
|
||||
cli = capitano.parse(process.argv)
|
||||
|
||||
runCommand = ->
|
||||
if cli.global?.help
|
||||
capitanoExecuteAsync(command: "help #{cli.command ? ''}")
|
||||
else
|
||||
capitanoExecuteAsync(cli)
|
||||
|
||||
Promise.all([events.trackCommand(cli), runCommand()])
|
||||
cli = capitano.parse(process.argv)
|
||||
runCommand = ->
|
||||
if cli.global?.help
|
||||
capitanoExecuteAsync(command: "help #{cli.command ? ''}")
|
||||
else
|
||||
capitanoExecuteAsync(cli)
|
||||
|
||||
Promise.all([events.trackCommand(cli), runCommand()])
|
||||
.catch(errors.handle)
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016 Resin.io
|
||||
Copyright 2016 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -18,19 +18,19 @@ limitations under the License.
|
||||
# @module auth
|
||||
###
|
||||
|
||||
open = require('open')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
open = require('opn')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
server = require('./server')
|
||||
utils = require('./utils')
|
||||
|
||||
###*
|
||||
# @summary Login to the Resin CLI using the web dashboard
|
||||
# @summary Login to the balena CLI using the web dashboard
|
||||
# @function
|
||||
# @public
|
||||
#
|
||||
# @description
|
||||
# This function opens the user's default browser and points it
|
||||
# to the Resin.io dashboard where the session token exchange will
|
||||
# to the balena dashboard where the session token exchange will
|
||||
# take place.
|
||||
#
|
||||
# Once the the token is retrieved, it's automatically persisted.
|
||||
@ -56,8 +56,8 @@ exports.login = ->
|
||||
# Leave a bit of time for the
|
||||
# server to get up and runing
|
||||
setTimeout ->
|
||||
open(loginUrl)
|
||||
open(loginUrl, { wait: false })
|
||||
, 1000
|
||||
|
||||
return server.awaitForToken(options)
|
||||
.tap(resin.auth.loginWithToken)
|
||||
.tap(balena.auth.loginWithToken)
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Resin 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>
|
||||
@ -12,10 +12,10 @@
|
||||
<div class="center">
|
||||
<img class="icon" src="./static/images/sad.png" inline>
|
||||
<h1>Something went wrong</h1>
|
||||
<p>You couldn't login to the Resin CLI for some reason</p>
|
||||
<p>You couldn't login to the balena CLI for some reason</p>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://forums.resin.io/" class="button danger">Get help in our forums</a>
|
||||
<a href="https://forums.balena.io/" class="button danger">Get help in our forums</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>Resin 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>
|
||||
@ -12,7 +12,7 @@
|
||||
<div class="center">
|
||||
<img class="icon" src="./static/images/happy.png" inline>
|
||||
<h1>Success!</h1>
|
||||
<p>You successfully logged in the Resin CLI</p>
|
||||
<p>You successfully logged in the balena CLI</p>
|
||||
<br>
|
||||
<br>
|
||||
<a href="<%= dashboardUrl %>" class="button normal">Go to the dashboard</a>
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016 Resin.io
|
||||
Copyright 2016 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -18,7 +18,7 @@ express = require('express')
|
||||
path = require('path')
|
||||
bodyParser = require('body-parser')
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
utils = require('./utils')
|
||||
|
||||
createServer = ({ port, isDev } = {}) ->
|
||||
@ -77,9 +77,9 @@ exports.awaitForToken = (options) ->
|
||||
Promise.try ->
|
||||
if not token
|
||||
throw new Error('No token')
|
||||
return utils.isTokenValid(token)
|
||||
.tap (isValid) ->
|
||||
if not isValid
|
||||
return utils.loginIfTokenValid(token)
|
||||
.tap (loggedIn) ->
|
||||
if not loggedIn
|
||||
throw new Error('Invalid token')
|
||||
.then ->
|
||||
renderAndDone({ request, response, viewName: 'success', token })
|
||||
@ -96,6 +96,6 @@ exports.awaitForToken = (options) ->
|
||||
exports.getContext = getContext = (viewName) ->
|
||||
if viewName is 'success'
|
||||
return Promise.props
|
||||
dashboardUrl: resin.settings.get('dashboardUrl')
|
||||
dashboardUrl: balena.settings.get('dashboardUrl')
|
||||
|
||||
return Promise.resolve({})
|
||||
|
@ -1,5 +1,5 @@
|
||||
###
|
||||
Copyright 2016 Resin.io
|
||||
Copyright 2016 Balena
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
###
|
||||
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
balena = require('balena-sdk').fromSharedOptions()
|
||||
_ = require('lodash')
|
||||
url = require('url')
|
||||
Promise = require('bluebird')
|
||||
@ -38,11 +38,11 @@ exports.getDashboardLoginURL = (callbackUrl) ->
|
||||
# characters to avoid angular getting confused.
|
||||
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25')
|
||||
|
||||
resin.settings.get('dashboardUrl').then (dashboardUrl) ->
|
||||
balena.settings.get('dashboardUrl').then (dashboardUrl) ->
|
||||
return url.resolve(dashboardUrl, "/login/cli/#{callbackUrl}")
|
||||
|
||||
###*
|
||||
# @summary Check if a token is valid
|
||||
# @summary Log in using a token, but only if the token is valid
|
||||
# @function
|
||||
# @protected
|
||||
#
|
||||
@ -50,26 +50,31 @@ exports.getDashboardLoginURL = (callbackUrl) ->
|
||||
# This function checks that the token is not only well-structured
|
||||
# but that it also authenticates with the server successfully.
|
||||
#
|
||||
# @param {String} sessionToken - token
|
||||
# @fulfil {Boolean} - whether is valid or not
|
||||
# If authenticated, the token is persisted, if not then the previous
|
||||
# login state is restored.
|
||||
#
|
||||
# @param {String} token - session token or api key
|
||||
# @fulfil {Boolean} - whether the login was successful or not
|
||||
# @returns {Promise}
|
||||
#
|
||||
# utils.isTokenValid('...').then (isValid) ->
|
||||
# if isValid
|
||||
# utils.loginIfTokenValid('...').then (loggedIn) ->
|
||||
# if loggedIn
|
||||
# console.log('Token is valid!')
|
||||
###
|
||||
exports.isTokenValid = (sessionToken) ->
|
||||
if not sessionToken? or _.isEmpty(sessionToken.trim())
|
||||
exports.loginIfTokenValid = (token) ->
|
||||
if not token? or _.isEmpty(token.trim())
|
||||
return Promise.resolve(false)
|
||||
|
||||
return resin.token.get().then (currentToken) ->
|
||||
resin.auth.loginWithToken(sessionToken)
|
||||
.return(sessionToken)
|
||||
.then(resin.auth.isLoggedIn)
|
||||
return balena.auth.getToken()
|
||||
.catchReturn(undefined)
|
||||
.then (currentToken) ->
|
||||
balena.auth.loginWithToken(token)
|
||||
.return(token)
|
||||
.then(balena.auth.isLoggedIn)
|
||||
.tap (isLoggedIn) ->
|
||||
return if isLoggedIn
|
||||
|
||||
if currentToken?
|
||||
return resin.auth.loginWithToken(currentToken)
|
||||
return balena.auth.loginWithToken(currentToken)
|
||||
else
|
||||
return resin.auth.logout()
|
||||
return balena.auth.logout()
|
||||
|
@ -1 +1,2 @@
|
||||
exports.sentryDsn = 'https://56d2a46124614b01b0f4086897e96110:6e175465accc41b595a96947155f61fb@sentry.io/149239'
|
||||
export const sentryDsn =
|
||||
'https://56d2a46124614b01b0f4086897e96110:6e175465accc41b595a96947155f61fb@sentry.io/149239';
|
||||
|
@ -1,38 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
chalk = require('chalk')
|
||||
errors = require('resin-cli-errors')
|
||||
patterns = require('./utils/patterns')
|
||||
Raven = require('raven')
|
||||
Promise = require('bluebird')
|
||||
|
||||
captureException = Promise.promisify(Raven.captureException.bind(Raven))
|
||||
|
||||
exports.handle = (error) ->
|
||||
message = errors.interpret(error)
|
||||
return if not message?
|
||||
|
||||
if process.env.DEBUG
|
||||
message = error.stack
|
||||
|
||||
patterns.printErrorMessage(message)
|
||||
|
||||
captureException(error)
|
||||
.timeout(1000)
|
||||
.catch(-> # Ignore any errors (from error logging, or timeouts)
|
||||
).finally ->
|
||||
process.exit(error.exitCode or 1)
|
125
lib/errors.ts
Normal file
125
lib/errors.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import * as Promise from 'bluebird';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import * as _ from 'lodash';
|
||||
import * as os from 'os';
|
||||
import * as Raven from 'raven';
|
||||
|
||||
import * as patterns from './utils/patterns';
|
||||
|
||||
const captureException = Promise.promisify<string, Error>(
|
||||
Raven.captureException,
|
||||
{ context: Raven },
|
||||
);
|
||||
|
||||
function hasCode(error: any): error is Error & { code: string } {
|
||||
return error.code != null;
|
||||
}
|
||||
|
||||
function treatFailedBindingAsMissingModule(error: any): void {
|
||||
if (error.message.startsWith('Could not locate the bindings file.')) {
|
||||
error.code = 'MODULE_NOT_FOUND';
|
||||
}
|
||||
}
|
||||
|
||||
function interpret(error: any): string | undefined {
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
treatFailedBindingAsMissingModule(error);
|
||||
|
||||
if (hasCode(error)) {
|
||||
const errorCodeHandler = messages[error.code];
|
||||
const message = errorCodeHandler && errorCodeHandler(error);
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!_.isEmpty(error.message)) {
|
||||
return `${error.code}: ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const messages: {
|
||||
[key: string]: (error: Error & { path?: string }) => string;
|
||||
} = {
|
||||
EISDIR: error => `File is a directory: ${error.path}`,
|
||||
|
||||
ENOENT: error => `No such file or directory: ${error.path}`,
|
||||
|
||||
ENOGIT: () => stripIndent`
|
||||
Git is not installed on this system.
|
||||
Head over to http://git-scm.com to install it and run this command again.`,
|
||||
|
||||
EPERM: () => stripIndent`
|
||||
You don't have enough privileges to run this operation.
|
||||
${
|
||||
os.platform() === 'win32'
|
||||
? 'Run a new Command Prompt as administrator and try running this command again.'
|
||||
: 'Try running this command again prefixing it with `sudo`.'
|
||||
}
|
||||
|
||||
If this is not the case, and you're trying to burn an SDCard, check that the write lock is not set.`,
|
||||
|
||||
EACCES: e => messages.EPERM(e),
|
||||
|
||||
ETIMEDOUT: () =>
|
||||
'Oops something went wrong, please check your connection and try again.',
|
||||
|
||||
MODULE_NOT_FOUND: () => stripIndent`
|
||||
Part of the CLI could not be loaded. This typically means your CLI install is in a broken state.
|
||||
${
|
||||
os.arch() === 'x64'
|
||||
? 'You can normally fix this by uninstalling and reinstalling the CLI.'
|
||||
: stripIndent`
|
||||
You're using an unsupported architecture (${os.arch()}), so this is typically caused by missing native modules.
|
||||
Reinstalling may help, but pay attention to errors in native module build steps en route.
|
||||
`
|
||||
}
|
||||
`,
|
||||
|
||||
BalenaExpiredToken: () => stripIndent`
|
||||
Looks like your session token is expired.
|
||||
Please try logging in again with:
|
||||
$ balena login`,
|
||||
};
|
||||
|
||||
exports.handle = function(error: any) {
|
||||
let message = interpret(error);
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
message = error.stack;
|
||||
}
|
||||
|
||||
patterns.printErrorMessage(message!);
|
||||
|
||||
return captureException(error)
|
||||
.timeout(1000)
|
||||
.catch(function() {
|
||||
// Ignore any errors (from error logging, or timeouts)
|
||||
})
|
||||
.finally(() => process.exit(error.exitCode || 1));
|
||||
};
|
@ -1,34 +0,0 @@
|
||||
_ = require('lodash')
|
||||
Mixpanel = require('mixpanel')
|
||||
Raven = require('raven')
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
packageJSON = require('../package.json')
|
||||
|
||||
exports.getLoggerInstance = _.memoize ->
|
||||
return resin.models.config.getMixpanelToken().then(Mixpanel.init)
|
||||
|
||||
exports.trackCommand = (capitanoCommand) ->
|
||||
capitanoStateGetMatchCommandAsync = Promise.promisify(require('capitano').state.getMatchCommand)
|
||||
|
||||
return Promise.props
|
||||
resinUrl: resin.settings.get('resinUrl')
|
||||
username: resin.auth.whoami().catchReturn(undefined)
|
||||
mixpanel: exports.getLoggerInstance()
|
||||
.then ({ username, resinUrl, mixpanel }) ->
|
||||
return capitanoStateGetMatchCommandAsync(capitanoCommand.command).then (command) ->
|
||||
Raven.mergeContext(user: {
|
||||
id: username,
|
||||
username
|
||||
})
|
||||
mixpanel.track "[CLI] #{command.signature.toString()}",
|
||||
distinct_id: username
|
||||
argv: process.argv.join(' ')
|
||||
version: packageJSON.version
|
||||
node: process.version
|
||||
arch: process.arch
|
||||
resinUrl: resinUrl
|
||||
platform: process.platform
|
||||
command: capitanoCommand
|
||||
.timeout(100)
|
||||
.catchReturn()
|
67
lib/events.ts
Normal file
67
lib/events.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @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 BalenaSdk = require('balena-sdk');
|
||||
import Promise = require('bluebird');
|
||||
import * as Capitano from 'capitano';
|
||||
import _ = require('lodash');
|
||||
import Mixpanel = require('mixpanel');
|
||||
import Raven = require('raven');
|
||||
|
||||
import packageJSON = require('../package.json');
|
||||
|
||||
const getBalenaSdk = _.once(() => BalenaSdk.fromSharedOptions());
|
||||
const getMatchCommandAsync = Promise.promisify(Capitano.state.getMatchCommand);
|
||||
const getMixpanel = _.once<any>(() => {
|
||||
const settings = require('balena-settings-client');
|
||||
return Mixpanel.init('00000000000000000000000000000000', {
|
||||
host: `api.${settings.get('balenaUrl')}`,
|
||||
path: '/mixpanel',
|
||||
protocol: 'https',
|
||||
});
|
||||
});
|
||||
|
||||
export function trackCommand(capitanoCli: Capitano.Cli) {
|
||||
const balena = getBalenaSdk();
|
||||
return Promise.props({
|
||||
balenaUrl: balena.settings.get('balenaUrl'),
|
||||
username: balena.auth.whoami().catchReturn(undefined),
|
||||
mixpanel: getMixpanel(),
|
||||
})
|
||||
.then(({ username, balenaUrl, mixpanel }) => {
|
||||
return getMatchCommandAsync(capitanoCli.command).then(command => {
|
||||
Raven.mergeContext({
|
||||
user: {
|
||||
id: username,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
return mixpanel.track(`[CLI] ${command.signature.toString()}`, {
|
||||
distinct_id: username,
|
||||
argv: process.argv.join(' '),
|
||||
version: packageJSON.version,
|
||||
node: process.version,
|
||||
arch: process.arch,
|
||||
balenaUrl,
|
||||
platform: process.platform,
|
||||
command: capitanoCli,
|
||||
});
|
||||
});
|
||||
})
|
||||
.timeout(100)
|
||||
.catchReturn(undefined);
|
||||
}
|
21
lib/utils/cloud.ts
Normal file
21
lib/utils/cloud.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BalenaSDK } from 'balena-sdk';
|
||||
import memoize = require('lodash/memoize');
|
||||
|
||||
export const serviceIdToName = memoize(
|
||||
async (sdk: BalenaSDK, serviceId: number): Promise<string | undefined> => {
|
||||
const serviceName = await sdk.pine.get({
|
||||
resource: 'service',
|
||||
id: serviceId,
|
||||
options: {
|
||||
$select: 'service_name',
|
||||
},
|
||||
});
|
||||
|
||||
if (serviceName != null) {
|
||||
return serviceName.service_name;
|
||||
}
|
||||
return;
|
||||
},
|
||||
// Memoize the call based on service id
|
||||
(_sdk, id) => id.toString(),
|
||||
);
|
826
lib/utils/compose.coffee
Normal file
826
lib/utils/compose.coffee
Normal file
@ -0,0 +1,826 @@
|
||||
###*
|
||||
# @license
|
||||
# Copyright 2018 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.
|
||||
###
|
||||
|
||||
Promise = require('bluebird')
|
||||
path = require('path')
|
||||
|
||||
exports.appendProjectOptions = appendProjectOptions = (opts) ->
|
||||
opts.concat [
|
||||
{
|
||||
signature: 'projectName'
|
||||
parameter: 'projectName'
|
||||
description: 'Specify an alternate project name; default is the directory name'
|
||||
alias: 'n'
|
||||
},
|
||||
]
|
||||
|
||||
exports.appendOptions = (opts) ->
|
||||
appendProjectOptions(opts).concat [
|
||||
{
|
||||
signature: 'emulated'
|
||||
description: 'Run an emulated build using Qemu'
|
||||
boolean: true
|
||||
alias: 'e'
|
||||
},
|
||||
{
|
||||
signature: 'dockerfile'
|
||||
parameter: 'Dockerfile'
|
||||
description: 'Alternative Dockerfile name/path, relative to the source folder'
|
||||
},
|
||||
{
|
||||
signature: 'logs'
|
||||
description: 'Display full log output'
|
||||
boolean: true
|
||||
},
|
||||
{
|
||||
signature: 'registry-secrets'
|
||||
alias: 'R'
|
||||
parameter: 'secrets.yml|.json'
|
||||
description: 'Path to a YAML or JSON file with passwords for a private Docker registry'
|
||||
},
|
||||
]
|
||||
|
||||
exports.generateOpts = (options) ->
|
||||
fs = require('mz/fs')
|
||||
fs.realpath(options.source || '.').then (projectPath) ->
|
||||
projectName: options.projectName
|
||||
projectPath: projectPath
|
||||
inlineLogs: !!options.logs
|
||||
dockerfilePath: options.dockerfile
|
||||
|
||||
compositionFileNames = [
|
||||
'docker-compose.yml'
|
||||
'docker-compose.yaml'
|
||||
]
|
||||
|
||||
# look into the given directory for valid compose files and return
|
||||
# the contents of the first one found.
|
||||
exports.resolveProject = resolveProject = (rootDir) ->
|
||||
fs = require('mz/fs')
|
||||
Promise.any compositionFileNames.map (filename) ->
|
||||
fs.readFile(path.join(rootDir, filename), 'utf-8')
|
||||
|
||||
# Parse the given composition and return a structure with info. Input is:
|
||||
# - composePath: the *absolute* path to the directory containing the compose file
|
||||
# - composeStr: the contents of the compose file, as a string
|
||||
createProject = (composePath, composeStr, projectName = null) ->
|
||||
yml = require('js-yaml')
|
||||
compose = require('resin-compose-parse')
|
||||
|
||||
# both methods below may throw.
|
||||
composition = yml.safeLoad(composeStr, schema: yml.FAILSAFE_SCHEMA)
|
||||
composition = compose.normalize(composition)
|
||||
|
||||
projectName ?= path.basename(composePath)
|
||||
descriptors = compose.parse(composition).map (descr) ->
|
||||
# generate an image name based on the project and service names
|
||||
# if one is not given and the service requires a build
|
||||
if descr.image.context? and not descr.image.tag?
|
||||
descr.image.tag = [ projectName, descr.serviceName ].join('_').toLowerCase()
|
||||
return descr
|
||||
return {
|
||||
path: composePath,
|
||||
name: projectName,
|
||||
composition,
|
||||
descriptors
|
||||
}
|
||||
|
||||
# high-level function resolving a project and creating a composition out
|
||||
# of it in one go. if image is given, it'll create a default project for
|
||||
# that without looking for a project. falls back to creating a default
|
||||
# project if none is found at the given projectPath.
|
||||
exports.loadProject = (logger, projectPath, projectName, image, dockerfilePath) ->
|
||||
{ validateSpecifiedDockerfile } = require('./compose_ts')
|
||||
compose = require('resin-compose-parse')
|
||||
logger.logDebug('Loading project...')
|
||||
|
||||
Promise.try ->
|
||||
dockerfilePath = validateSpecifiedDockerfile(projectPath, dockerfilePath)
|
||||
|
||||
if image?
|
||||
logger.logInfo("Creating default composition with image: #{image}")
|
||||
return compose.defaultComposition(image)
|
||||
|
||||
logger.logDebug('Resolving project...')
|
||||
|
||||
resolveProject(projectPath)
|
||||
.tap ->
|
||||
if dockerfilePath
|
||||
logger.logWarn("Ignoring alternative dockerfile \"#{dockerfilePath}\"\ because a docker-compose file exists")
|
||||
else
|
||||
logger.logInfo('Compose file detected')
|
||||
.catch (e) ->
|
||||
logger.logDebug("Failed to resolve project: #{e}")
|
||||
logger.logInfo("Creating default composition with source: #{projectPath}")
|
||||
return compose.defaultComposition(undefined, dockerfilePath)
|
||||
.then (composeStr) ->
|
||||
logger.logDebug('Creating project...')
|
||||
createProject(projectPath, composeStr, projectName)
|
||||
|
||||
exports.tarDirectory = tarDirectory = (dir, preFinalizeCallback = null) ->
|
||||
tar = require('tar-stream')
|
||||
klaw = require('klaw')
|
||||
path = require('path')
|
||||
fs = require('mz/fs')
|
||||
streamToPromise = require('stream-to-promise')
|
||||
{ FileIgnorer } = require('./ignore')
|
||||
{ toPosixPath } = require('resin-multibuild').PathUtils
|
||||
|
||||
getFiles = ->
|
||||
streamToPromise(klaw(dir))
|
||||
.filter((item) -> not item.stats.isDirectory())
|
||||
.map((item) -> item.path)
|
||||
|
||||
ignore = new FileIgnorer(dir)
|
||||
pack = tar.pack()
|
||||
getFiles(dir)
|
||||
.each (file) ->
|
||||
type = ignore.getIgnoreFileType(path.relative(dir, file))
|
||||
if type?
|
||||
ignore.addIgnoreFile(file, type)
|
||||
.filter(ignore.filter)
|
||||
.map (file) ->
|
||||
relPath = path.relative(path.resolve(dir), file)
|
||||
Promise.join relPath, fs.stat(file), fs.readFile(file),
|
||||
(filename, stats, data) ->
|
||||
pack.entry({ name: toPosixPath(filename), size: stats.size, mode: stats.mode }, data)
|
||||
.then ->
|
||||
preFinalizeCallback?(pack)
|
||||
.then ->
|
||||
pack.finalize()
|
||||
return pack
|
||||
|
||||
truncateString = (str, len) ->
|
||||
return str if str.length < len
|
||||
str = str.slice(0, len)
|
||||
# return everything up to the last line. this is a cheeky way to avoid
|
||||
# having to deal with splitting the string midway through some special
|
||||
# character sequence.
|
||||
return str.slice(0, str.lastIndexOf('\n'))
|
||||
|
||||
LOG_LENGTH_MAX = 512 * 1024 # 512KB
|
||||
|
||||
exports.buildProject = (
|
||||
docker, logger,
|
||||
projectPath, projectName, composition,
|
||||
arch, deviceType,
|
||||
emulated, buildOpts,
|
||||
inlineLogs
|
||||
) ->
|
||||
_ = require('lodash')
|
||||
humanize = require('humanize')
|
||||
compose = require('resin-compose-parse')
|
||||
builder = require('resin-multibuild')
|
||||
transpose = require('docker-qemu-transpose')
|
||||
qemu = require('./qemu')
|
||||
{ toPosixPath } = builder.PathUtils
|
||||
|
||||
logger.logInfo("Building for #{arch}/#{deviceType}")
|
||||
|
||||
imageDescriptors = compose.parse(composition)
|
||||
imageDescriptorsByServiceName = _.keyBy(imageDescriptors, 'serviceName')
|
||||
|
||||
if inlineLogs
|
||||
renderer = new BuildProgressInline(logger.streams['build'], imageDescriptors)
|
||||
else
|
||||
tty = require('./tty')(process.stdout)
|
||||
renderer = new BuildProgressUI(tty, imageDescriptors)
|
||||
renderer.start()
|
||||
|
||||
qemu.installQemuIfNeeded(emulated, logger, arch)
|
||||
.tap (needsQemu) ->
|
||||
return if not needsQemu
|
||||
logger.logInfo('Emulation is enabled')
|
||||
# Copy qemu into all build contexts
|
||||
Promise.map imageDescriptors, (d) ->
|
||||
return if not d.image.context? # external image
|
||||
return qemu.copyQemu(path.join(projectPath, d.image.context), arch)
|
||||
.then (needsQemu) ->
|
||||
# Tar up the directory, ready for the build stream
|
||||
tarDirectory(projectPath)
|
||||
.then (tarStream) ->
|
||||
{ makeBuildTasks } = require('./compose_ts')
|
||||
Promise.resolve(makeBuildTasks(composition, tarStream, { arch, deviceType }, logger))
|
||||
.map (task) ->
|
||||
d = imageDescriptorsByServiceName[task.serviceName]
|
||||
|
||||
# multibuild parses the composition internally so any tags we've
|
||||
# set before are lost; re-assign them here
|
||||
task.tag ?= [ projectName, task.serviceName ].join('_').toLowerCase()
|
||||
if d.image.context?
|
||||
d.image.tag = task.tag
|
||||
|
||||
# configure build opts appropriately
|
||||
task.dockerOpts ?= {}
|
||||
_.merge(task.dockerOpts, buildOpts, { t: task.tag })
|
||||
if d.image.context?.args?
|
||||
task.dockerOpts.buildargs ?= {}
|
||||
_.merge(task.dockerOpts.buildargs, d.image.context.args)
|
||||
|
||||
# Get the service-specific log stream
|
||||
# Caveat: `multibuild.BuildTask` defines no `logStream` property
|
||||
# but it's convenient to store it there; it's JS ultimately.
|
||||
task.logStream = renderer.streams[task.serviceName]
|
||||
task.logBuffer = []
|
||||
|
||||
# Setup emulation if needed
|
||||
return [ task, null ] if task.external or not needsQemu
|
||||
binPath = qemu.qemuPathInContext(path.join(projectPath, task.context))
|
||||
transpose.transposeTarStream task.buildStream,
|
||||
hostQemuPath: toPosixPath(binPath)
|
||||
containerQemuPath: "/tmp/#{qemu.QEMU_BIN_NAME}"
|
||||
.then (stream) ->
|
||||
task.buildStream = stream
|
||||
.return([ task, binPath ])
|
||||
.map ([ task, qemuPath ]) ->
|
||||
Promise.resolve(task).tap (task) ->
|
||||
captureStream = buildLogCapture(task.external, task.logBuffer)
|
||||
|
||||
if task.external
|
||||
# External image -- there's no build to be performed,
|
||||
# just follow pull progress.
|
||||
captureStream.pipe(task.logStream)
|
||||
task.progressHook = pullProgressAdapter(captureStream)
|
||||
else
|
||||
task.streamHook = (stream) ->
|
||||
stream = createLogStream(stream)
|
||||
if qemuPath?
|
||||
buildThroughStream = transpose.getBuildThroughStream
|
||||
hostQemuPath: toPosixPath(qemuPath)
|
||||
containerQemuPath: "/tmp/#{qemu.QEMU_BIN_NAME}"
|
||||
rawStream = stream.pipe(buildThroughStream)
|
||||
else
|
||||
rawStream = stream
|
||||
# `stream` sends out raw strings in contrast to `task.progressHook`
|
||||
# where we're given objects. capture these strings as they come
|
||||
# before we parse them.
|
||||
rawStream
|
||||
.pipe(dropEmptyLinesStream())
|
||||
.pipe(captureStream)
|
||||
.pipe(buildProgressAdapter(inlineLogs))
|
||||
.pipe(task.logStream)
|
||||
.then (tasks) ->
|
||||
logger.logDebug 'Prepared tasks; building...'
|
||||
builder.performBuilds(tasks, docker)
|
||||
.map (builtImage) ->
|
||||
if not builtImage.successful
|
||||
builtImage.error.serviceName = builtImage.serviceName
|
||||
throw builtImage.error
|
||||
|
||||
d = imageDescriptorsByServiceName[builtImage.serviceName]
|
||||
task = _.find(tasks, serviceName: builtImage.serviceName)
|
||||
|
||||
image =
|
||||
serviceName: d.serviceName
|
||||
name: d.image.tag ? d.image
|
||||
logs: truncateString(task.logBuffer.join('\n'), LOG_LENGTH_MAX)
|
||||
props:
|
||||
dockerfile: builtImage.dockerfile
|
||||
projectType: builtImage.projectType
|
||||
|
||||
# Times here are timestamps, so test whether they're null
|
||||
# before creating a date out of them, as `new Date(null)`
|
||||
# creates a date representing UNIX time 0.
|
||||
if (startTime = builtImage.startTime)
|
||||
image.props.startTime = new Date(startTime)
|
||||
if (endTime = builtImage.endTime)
|
||||
image.props.endTime = new Date(endTime)
|
||||
docker.getImage(image.name).inspect().get('Size').then (size) ->
|
||||
image.props.size = size
|
||||
.return(image)
|
||||
.tap (images) ->
|
||||
summary = _(images).map ({ serviceName, props }) ->
|
||||
[ serviceName, "Image size: #{humanize.filesize(props.size)}" ]
|
||||
.fromPairs()
|
||||
.value()
|
||||
renderer.end(summary)
|
||||
.finally(renderer.end)
|
||||
|
||||
createRelease = (apiEndpoint, auth, userId, appId, composition) ->
|
||||
_ = require('lodash')
|
||||
crypto = require('crypto')
|
||||
releaseMod = require('resin-release')
|
||||
|
||||
client = releaseMod.createClient({ apiEndpoint, auth })
|
||||
|
||||
releaseMod.create
|
||||
client: client
|
||||
user: userId
|
||||
application: appId
|
||||
composition: composition
|
||||
source: 'local'
|
||||
commit: crypto.pseudoRandomBytes(16).toString('hex').toLowerCase()
|
||||
.then ({ release, serviceImages }) ->
|
||||
release = _.omit(release, [
|
||||
'created_at'
|
||||
'belongs_to__application'
|
||||
'is_created_by__user'
|
||||
'__metadata'
|
||||
])
|
||||
serviceImages = _.mapValues serviceImages, (serviceImage) ->
|
||||
_.omit(serviceImage, [
|
||||
'created_at'
|
||||
'is_a_build_of__service'
|
||||
'__metadata'
|
||||
])
|
||||
return { client, release, serviceImages }
|
||||
|
||||
tagServiceImages = (docker, images, serviceImages) ->
|
||||
Promise.map images, (d) ->
|
||||
serviceImage = serviceImages[d.serviceName]
|
||||
imageName = serviceImage.is_stored_at__image_location
|
||||
# coffeelint: disable-next-line=check_scope ("Variable is assigned to but never read")
|
||||
[ _match, registry, repo, tag = 'latest' ] = /(.*?)\/(.*?)(?::([^/]*))?$/.exec(imageName)
|
||||
name = "#{registry}/#{repo}"
|
||||
docker.getImage(d.name).tag({ repo: name, tag, force: true })
|
||||
.then ->
|
||||
docker.getImage("#{name}:#{tag}")
|
||||
.then (localImage) ->
|
||||
serviceName: d.serviceName
|
||||
serviceImage: serviceImage
|
||||
localImage: localImage
|
||||
registry: registry
|
||||
repo: repo
|
||||
logs: d.logs
|
||||
props: d.props
|
||||
|
||||
|
||||
getPreviousRepos = (sdk, docker, logger, appID) ->
|
||||
sdk.pine.get(
|
||||
resource: 'release'
|
||||
options:
|
||||
$filter:
|
||||
belongs_to__application: appID
|
||||
status: 'success'
|
||||
$select:
|
||||
[ 'id' ]
|
||||
$expand:
|
||||
contains__image:
|
||||
$expand: 'image'
|
||||
$orderby: 'id desc'
|
||||
$top: 1
|
||||
)
|
||||
.then (release) ->
|
||||
# grab all images from the latest release, return all image locations in the registry
|
||||
if release?.length > 0
|
||||
images = release[0].contains__image
|
||||
Promise.map images, (d) ->
|
||||
imageName = d.image[0].is_stored_at__image_location
|
||||
docker.getRegistryAndName(imageName)
|
||||
.then ( registry ) ->
|
||||
logger.logDebug("Requesting access to previously pushed image repo (#{registry.imageName})")
|
||||
return registry.imageName
|
||||
.catch (e) ->
|
||||
logger.logDebug("Failed to access previously pushed image repo: #{e}")
|
||||
|
||||
authorizePush = (sdk, logger, tokenAuthEndpoint, registry, images, previousRepos) ->
|
||||
_ = require('lodash')
|
||||
|
||||
# TODO: https://github.com/balena-io/balena-cli/issues/1070
|
||||
maxRepos = 20
|
||||
|
||||
if not _.isArray(images)
|
||||
images = [ images ]
|
||||
|
||||
if images.length > maxRepos
|
||||
throw new Error (
|
||||
"More than #{maxRepos} containers is currently not supported, see " +
|
||||
'https://github.com/balena-io/balena-cli/issues/1070 for more information'
|
||||
)
|
||||
images.push previousRepos...
|
||||
if images.length + previousRepos?.length > maxRepos
|
||||
logger.logDebug("Truncating requested repositories to #{maxRepos} by limiting previously pushed repo access")
|
||||
# at this point, we know we're only truncating access to previously pushed repos
|
||||
images = images[0...maxRepos]
|
||||
sdk.request.send
|
||||
baseUrl: tokenAuthEndpoint
|
||||
url: '/auth/v1/token'
|
||||
qs:
|
||||
service: registry
|
||||
scope: images.map (repo) ->
|
||||
"repository:#{repo}:pull,push"
|
||||
.get('body')
|
||||
.get('token')
|
||||
.catchReturn({})
|
||||
|
||||
pushAndUpdateServiceImages = (docker, token, images, afterEach) ->
|
||||
chalk = require('chalk')
|
||||
{ DockerProgress } = require('docker-progress')
|
||||
{ retry } = require('./helpers')
|
||||
tty = require('./tty')(process.stdout)
|
||||
|
||||
opts = { authconfig: registrytoken: token }
|
||||
|
||||
progress = new DockerProgress(dockerToolbelt: docker)
|
||||
renderer = pushProgressRenderer(tty, chalk.blue('[Push]') + ' ')
|
||||
reporters = progress.aggregateProgress(images.length, renderer)
|
||||
|
||||
Promise.using tty.cursorHidden(), ->
|
||||
Promise.map images, ({ serviceImage, localImage, props, logs }, index) ->
|
||||
Promise.join(
|
||||
localImage.inspect().get('Size')
|
||||
retry(
|
||||
-> progress.push(localImage.name, reporters[index], opts)
|
||||
3 # `times` - retry 3 times
|
||||
localImage.name # `label` included in retry log messages
|
||||
2000 # `delayMs` - wait 2 seconds before the 1st retry
|
||||
1.4 # `backoffScaler` - wait multiplier for each retry
|
||||
).finally(renderer.end)
|
||||
(size, digest) ->
|
||||
serviceImage.image_size = size
|
||||
serviceImage.content_hash = digest
|
||||
serviceImage.build_log = logs
|
||||
serviceImage.dockerfile = props.dockerfile
|
||||
serviceImage.project_type = props.projectType
|
||||
serviceImage.start_timestamp = props.startTime if props.startTime
|
||||
serviceImage.end_timestamp = props.endTime if props.endTime
|
||||
serviceImage.push_timestamp = new Date()
|
||||
serviceImage.status = 'success'
|
||||
)
|
||||
.tapCatch (e) ->
|
||||
serviceImage.error_message = '' + e
|
||||
serviceImage.status = 'failed'
|
||||
.finally ->
|
||||
afterEach?(serviceImage, props)
|
||||
|
||||
exports.deployProject = (
|
||||
docker, logger,
|
||||
composition, images,
|
||||
appId, userId, auth,
|
||||
apiEndpoint,
|
||||
skipLogUpload
|
||||
) ->
|
||||
_ = require('lodash')
|
||||
chalk = require('chalk')
|
||||
releaseMod = require('resin-release')
|
||||
tty = require('./tty')(process.stdout)
|
||||
|
||||
prefix = chalk.cyan('[Info]') + ' '
|
||||
spinner = createSpinner()
|
||||
runloop = runSpinner(tty, spinner, "#{prefix}Creating release...")
|
||||
|
||||
createRelease(apiEndpoint, auth, userId, appId, composition)
|
||||
.finally(runloop.end)
|
||||
.then ({ client, release, serviceImages }) ->
|
||||
logger.logDebug('Tagging images...')
|
||||
tagServiceImages(docker, images, serviceImages)
|
||||
.tap (images) ->
|
||||
logger.logDebug('Authorizing push...')
|
||||
sdk = require('balena-sdk').fromSharedOptions()
|
||||
getPreviousRepos(sdk, docker, logger, appId)
|
||||
.then (previousRepos) ->
|
||||
authorizePush(sdk, logger, apiEndpoint, images[0].registry, _.map(images, 'repo'), previousRepos)
|
||||
.then (token) ->
|
||||
logger.logInfo('Pushing images to registry...')
|
||||
pushAndUpdateServiceImages docker, token, images, (serviceImage) ->
|
||||
logger.logDebug("Saving image #{serviceImage.is_stored_at__image_location}")
|
||||
if skipLogUpload
|
||||
delete serviceImage.build_log
|
||||
releaseMod.updateImage(client, serviceImage.id, serviceImage)
|
||||
.finally ->
|
||||
logger.logDebug('Untagging images...')
|
||||
Promise.map images, ({ localImage }) ->
|
||||
localImage.remove()
|
||||
.then ->
|
||||
release.status = 'success'
|
||||
.tapCatch ->
|
||||
release.status = 'failed'
|
||||
.finally ->
|
||||
runloop = runSpinner(tty, spinner, "#{prefix}Saving release...")
|
||||
release.end_timestamp = new Date()
|
||||
releaseMod.updateRelease(client, release.id, release)
|
||||
.finally(runloop.end)
|
||||
.return(release)
|
||||
|
||||
# utilities
|
||||
|
||||
renderProgressBar = (percentage, stepCount) ->
|
||||
_ = require('lodash')
|
||||
percentage = _.clamp(percentage, 0, 100)
|
||||
barCount = stepCount * percentage // 100
|
||||
spaceCount = stepCount - barCount
|
||||
bar = "[#{_.repeat('=', barCount)}>#{_.repeat(' ', spaceCount)}]"
|
||||
return "#{bar} #{_.padStart(percentage, 3)}%"
|
||||
|
||||
pushProgressRenderer = (tty, prefix) ->
|
||||
fn = (e) ->
|
||||
{ error, percentage } = e
|
||||
throw new Error(error) if error?
|
||||
bar = renderProgressBar(percentage, 40)
|
||||
tty.replaceLine("#{prefix}#{bar}\r")
|
||||
fn.end = ->
|
||||
tty.clearLine()
|
||||
return fn
|
||||
|
||||
createLogStream = (input) ->
|
||||
split = require('split')
|
||||
stripAnsi = require('strip-ansi-stream')
|
||||
return input.pipe(stripAnsi()).pipe(split())
|
||||
|
||||
dropEmptyLinesStream = ->
|
||||
through = require('through2')
|
||||
through (data, enc, cb) ->
|
||||
str = data.toString('utf-8')
|
||||
@push(str) if str.trim()
|
||||
cb()
|
||||
|
||||
buildLogCapture = (objectMode, buffer) ->
|
||||
through = require('through2')
|
||||
|
||||
through { objectMode }, (data, enc, cb) ->
|
||||
# data from pull stream
|
||||
if data.error
|
||||
buffer.push("#{data.error}")
|
||||
else if data.progress and data.status
|
||||
buffer.push("#{data.progress}% #{data.status}")
|
||||
else if data.status
|
||||
buffer.push("#{data.status}")
|
||||
|
||||
# data from build stream
|
||||
else
|
||||
buffer.push(data)
|
||||
|
||||
cb(null, data)
|
||||
|
||||
buildProgressAdapter = (inline) ->
|
||||
through = require('through2')
|
||||
|
||||
stepRegex = /^\s*Step\s+(\d+)\/(\d+)\s*: (.+)$/
|
||||
|
||||
[ step, numSteps, progress ] = [ null, null, undefined ]
|
||||
|
||||
through { objectMode: true }, (str, enc, cb) ->
|
||||
return cb(null, str) if not str?
|
||||
|
||||
if inline
|
||||
return cb(null, { status: str })
|
||||
|
||||
if /^Successfully tagged /.test(str)
|
||||
progress = undefined
|
||||
else
|
||||
if (match = stepRegex.exec(str))
|
||||
step = match[1]
|
||||
numSteps ?= match[2]
|
||||
str = match[3]
|
||||
if step?
|
||||
str = "Step #{step}/#{numSteps}: #{str}"
|
||||
progress = parseInt(step, 10) * 100 // parseInt(numSteps, 10)
|
||||
|
||||
cb(null, { status: str, progress })
|
||||
|
||||
pullProgressAdapter = (outStream) ->
|
||||
return ({ status, id, percentage, error, errorDetail }) ->
|
||||
if status?
|
||||
status = status.replace(/^Status: /, '')
|
||||
if id?
|
||||
status = "#{id}: #{status}"
|
||||
if percentage is 100
|
||||
percentage = undefined
|
||||
outStream.write
|
||||
status: status
|
||||
progress: percentage
|
||||
error: errorDetail?.message ? error
|
||||
|
||||
createSpinner = ->
|
||||
chars = '|/-\\'
|
||||
index = 0
|
||||
-> chars[(index++) % chars.length]
|
||||
|
||||
runSpinner = (tty, spinner, msg) ->
|
||||
runloop = createRunLoop ->
|
||||
tty.clearLine()
|
||||
tty.writeLine("#{msg} #{spinner()}")
|
||||
tty.cursorUp()
|
||||
runloop.onEnd = ->
|
||||
tty.clearLine()
|
||||
tty.writeLine(msg)
|
||||
return runloop
|
||||
|
||||
createRunLoop = (tick) ->
|
||||
timerId = setInterval(tick, 1000 / 10)
|
||||
runloop = {
|
||||
onEnd: ->
|
||||
end: ->
|
||||
clearInterval(timerId)
|
||||
runloop.onEnd()
|
||||
}
|
||||
return runloop
|
||||
|
||||
class BuildProgressUI
|
||||
constructor: (tty, descriptors) ->
|
||||
_ = require('lodash')
|
||||
chalk = require('chalk')
|
||||
through = require('through2')
|
||||
|
||||
eventHandler = @_handleEvent
|
||||
services = _.map(descriptors, 'serviceName')
|
||||
|
||||
streams = _(services).map (service) ->
|
||||
stream = through.obj (event, _enc, cb) ->
|
||||
eventHandler(service, event)
|
||||
cb()
|
||||
stream.pipe(tty.stream)
|
||||
[ service, stream ]
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
@_tty = tty
|
||||
@_serviceToDataMap = {}
|
||||
@_services = services
|
||||
|
||||
# Logger magically prefixes the log line with [Build] etc., but it doesn't
|
||||
# work well with the spinner we're also showing. Manually build the prefix
|
||||
# here and bypass the logger.
|
||||
prefix = chalk.blue('[Build]') + ' '
|
||||
|
||||
offset = 10 # account for escape sequences inserted for colouring
|
||||
@_prefixWidth = offset + prefix.length + _.max(_.map(services, 'length'))
|
||||
@_prefix = prefix
|
||||
|
||||
# these are to handle window wrapping
|
||||
@_maxLineWidth = null
|
||||
@_lineWidths = []
|
||||
|
||||
@_startTime = null
|
||||
@_ended = false
|
||||
@_cancelled = false
|
||||
@_spinner = createSpinner()
|
||||
|
||||
@streams = streams
|
||||
|
||||
_handleEvent: (service, event) =>
|
||||
@_serviceToDataMap[service] = event
|
||||
|
||||
_handleInterrupt: =>
|
||||
@_cancelled = true
|
||||
@end()
|
||||
process.exit(130) # 128 + SIGINT
|
||||
|
||||
start: =>
|
||||
process.on('SIGINT', @_handleInterrupt)
|
||||
@_tty.hideCursor()
|
||||
@_services.forEach (service) =>
|
||||
@streams[service].write({ status: 'Preparing...' })
|
||||
@_runloop = createRunLoop(@_display)
|
||||
@_startTime = Date.now()
|
||||
|
||||
end: (summary = null) =>
|
||||
return if @_ended
|
||||
@_ended = true
|
||||
process.removeListener('SIGINT', @_handleInterrupt)
|
||||
@_runloop.end()
|
||||
@_runloop = null
|
||||
|
||||
@_clear()
|
||||
@_renderStatus(true)
|
||||
@_renderSummary(summary ? @_getServiceSummary())
|
||||
@_tty.showCursor()
|
||||
|
||||
_display: =>
|
||||
@_clear()
|
||||
@_renderStatus()
|
||||
@_renderSummary(@_getServiceSummary())
|
||||
@_tty.cursorUp(@_services.length + 1) # for status line
|
||||
|
||||
_clear: ->
|
||||
@_tty.deleteToEnd()
|
||||
@_maxLineWidth = @_tty.currentWindowSize().width
|
||||
|
||||
_getServiceSummary: ->
|
||||
_ = require('lodash')
|
||||
|
||||
services = @_services
|
||||
serviceToDataMap = @_serviceToDataMap
|
||||
|
||||
_(services).map (service) ->
|
||||
{ status, progress, error } = serviceToDataMap[service] ? {}
|
||||
if error
|
||||
return "#{error}"
|
||||
else if progress
|
||||
bar = renderProgressBar(progress, 20)
|
||||
return "#{bar} #{status}" if status
|
||||
return "#{bar}"
|
||||
else if status
|
||||
return "#{status}"
|
||||
else
|
||||
return 'Waiting...'
|
||||
.map (data, index) ->
|
||||
[ services[index], data ]
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
_renderStatus: (end = false) ->
|
||||
moment = require('moment')
|
||||
require('moment-duration-format')(moment)
|
||||
|
||||
@_tty.clearLine()
|
||||
@_tty.write(@_prefix)
|
||||
if end and @_cancelled
|
||||
@_tty.writeLine('Build cancelled')
|
||||
else if end
|
||||
serviceCount = @_services.length
|
||||
serviceStr = if serviceCount is 1 then '1 service' else "#{serviceCount} services"
|
||||
runTime = Date.now() - @_startTime
|
||||
durationStr = moment.duration(runTime // 1000, 'seconds').format()
|
||||
@_tty.writeLine("Built #{serviceStr} in #{durationStr}")
|
||||
else
|
||||
@_tty.writeLine("Building services... #{@_spinner()}")
|
||||
|
||||
_renderSummary: (serviceToStrMap) ->
|
||||
_ = require('lodash')
|
||||
chalk = require('chalk')
|
||||
truncate = require('cli-truncate')
|
||||
strlen = require('string-width')
|
||||
|
||||
@_services.forEach (service, index) =>
|
||||
str = _.padEnd(@_prefix + chalk.bold(service), @_prefixWidth)
|
||||
str += serviceToStrMap[service]
|
||||
if @_maxLineWidth?
|
||||
str = truncate(str, @_maxLineWidth)
|
||||
@_lineWidths[index] = strlen(str)
|
||||
|
||||
@_tty.clearLine()
|
||||
@_tty.writeLine(str)
|
||||
|
||||
class BuildProgressInline
|
||||
constructor: (outStream, descriptors) ->
|
||||
_ = require('lodash')
|
||||
through = require('through2')
|
||||
|
||||
services = _.map(descriptors, 'serviceName')
|
||||
eventHandler = @_renderEvent
|
||||
streams = _(services).map (service) ->
|
||||
stream = through.obj (event, _enc, cb) ->
|
||||
eventHandler(service, event)
|
||||
cb()
|
||||
stream.pipe(outStream)
|
||||
[ service, stream ]
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
offset = 10 # account for escape sequences inserted for colouring
|
||||
@_prefixWidth = offset + _.max(_.map(services, 'length'))
|
||||
@_outStream = outStream
|
||||
@_services = services
|
||||
@_startTime = null
|
||||
@_ended = false
|
||||
|
||||
@streams = streams
|
||||
|
||||
start: =>
|
||||
@_outStream.write('Building services...\n')
|
||||
@_services.forEach (service) =>
|
||||
@streams[service].write({ status: 'Preparing...' })
|
||||
@_startTime = Date.now()
|
||||
|
||||
end: (summary = null) =>
|
||||
moment = require('moment')
|
||||
require('moment-duration-format')(moment)
|
||||
|
||||
return if @_ended
|
||||
@_ended = true
|
||||
|
||||
if summary?
|
||||
@_services.forEach (service) =>
|
||||
@_renderEvent(service, summary[service])
|
||||
|
||||
if @_cancelled
|
||||
@_outStream.write('Build cancelled\n')
|
||||
else
|
||||
serviceCount = @_services.length
|
||||
serviceStr = if serviceCount is 1 then '1 service' else "#{serviceCount} services"
|
||||
runTime = Date.now() - @_startTime
|
||||
durationStr = moment.duration(runTime // 1000, 'seconds').format()
|
||||
@_outStream.write("Built #{serviceStr} in #{durationStr}\n")
|
||||
|
||||
_renderEvent: (service, event) =>
|
||||
_ = require('lodash')
|
||||
chalk = require('chalk')
|
||||
|
||||
str = do ->
|
||||
{ status, error } = event
|
||||
if error
|
||||
return "#{error}"
|
||||
else if status
|
||||
return "#{status}"
|
||||
else
|
||||
return 'Waiting...'
|
||||
|
||||
prefix = _.padEnd(chalk.bold(service), @_prefixWidth)
|
||||
@_outStream.write(prefix)
|
||||
@_outStream.write(str)
|
||||
@_outStream.write('\n')
|
55
lib/utils/compose.d.ts
vendored
Normal file
55
lib/utils/compose.d.ts
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 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 Bluebird from 'bluebird';
|
||||
import { Composition } from 'resin-compose-parse';
|
||||
import * as Stream from 'stream';
|
||||
import { Pack } from 'tar-stream';
|
||||
|
||||
import Logger = require('./logger');
|
||||
|
||||
interface Image {
|
||||
context: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface Descriptor {
|
||||
image: Image | string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export function resolveProject(projectRoot: string): Bluebird<string>;
|
||||
|
||||
export interface ComposeProject {
|
||||
path: string;
|
||||
name: string;
|
||||
composition: Composition;
|
||||
descriptors: Descriptor[];
|
||||
}
|
||||
|
||||
export function loadProject(
|
||||
logger: Logger,
|
||||
projectPath: string,
|
||||
projectName: string,
|
||||
image?: string,
|
||||
dockerfilePath?: string,
|
||||
): Bluebird<ComposeProject>;
|
||||
|
||||
export function tarDirectory(
|
||||
source: string,
|
||||
preFinalizeCallback?: (pack: Pack) => void,
|
||||
): Promise<Stream.Readable>;
|
224
lib/utils/compose_ts.ts
Normal file
224
lib/utils/compose_ts.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 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 Bluebird from 'bluebird';
|
||||
import * as _ from 'lodash';
|
||||
import { Composition } from 'resin-compose-parse';
|
||||
import * as MultiBuild from 'resin-multibuild';
|
||||
import { Readable } from 'stream';
|
||||
import * as tar from 'tar-stream';
|
||||
|
||||
import { DeviceInfo } from './device/api';
|
||||
import Logger = require('./logger');
|
||||
|
||||
export interface RegistrySecrets {
|
||||
[registryAddress: string]: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseRegistrySecrets(
|
||||
secretsFilename: string,
|
||||
): Promise<RegistrySecrets> {
|
||||
const { fs } = await import('mz');
|
||||
const { exitWithExpectedError } = await import('../utils/patterns');
|
||||
try {
|
||||
let isYaml = false;
|
||||
if (/.+\.ya?ml$/i.test(secretsFilename)) {
|
||||
isYaml = true;
|
||||
} else if (!/.+\.json$/i.test(secretsFilename)) {
|
||||
throw new Error('Filename must end with .json, .yml or .yaml');
|
||||
}
|
||||
const raw = (await fs.readFile(secretsFilename)).toString();
|
||||
const registrySecrets = new MultiBuild.RegistrySecretValidator().validateRegistrySecrets(
|
||||
isYaml ? require('js-yaml').safeLoad(raw) : JSON.parse(raw),
|
||||
);
|
||||
MultiBuild.addCanonicalDockerHubEntry(registrySecrets);
|
||||
return registrySecrets;
|
||||
} catch (error) {
|
||||
return exitWithExpectedError(
|
||||
`Error validating registry secrets file "${secretsFilename}":\n${
|
||||
error.message
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the compose-specific command-line options defined in compose.coffee.
|
||||
* This function is meant to be called very early on to validate users' input,
|
||||
* before any project loading / building / deploying.
|
||||
*/
|
||||
export async function validateComposeOptions(options: { [opt: string]: any }) {
|
||||
if (options['registry-secrets']) {
|
||||
options['registry-secrets'] = await parseRegistrySecrets(
|
||||
options['registry-secrets'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BuildTask array of "resolved build tasks" by calling multibuild
|
||||
* .splitBuildStream() and performResolution(), and add build stream error
|
||||
* handlers and debug logging.
|
||||
* Both `balena build` and `balena deploy` call this function.
|
||||
*/
|
||||
export async function makeBuildTasks(
|
||||
composition: Composition,
|
||||
tarStream: Readable,
|
||||
deviceInfo: DeviceInfo,
|
||||
logger: Logger,
|
||||
): Promise<MultiBuild.BuildTask[]> {
|
||||
const buildTasks = await MultiBuild.splitBuildStream(composition, tarStream);
|
||||
|
||||
logger.logDebug('Found build tasks:');
|
||||
_.each(buildTasks, task => {
|
||||
let infoStr: string;
|
||||
if (task.external) {
|
||||
infoStr = `image pull [${task.imageName}]`;
|
||||
} else {
|
||||
infoStr = `build [${task.context}]`;
|
||||
}
|
||||
logger.logDebug(` ${task.serviceName}: ${infoStr}`);
|
||||
});
|
||||
|
||||
logger.logDebug(
|
||||
`Resolving services with [${deviceInfo.deviceType}|${deviceInfo.arch}]`,
|
||||
);
|
||||
|
||||
await performResolution(buildTasks, deviceInfo);
|
||||
|
||||
logger.logDebug('Found project types:');
|
||||
_.each(buildTasks, task => {
|
||||
if (task.external) {
|
||||
logger.logDebug(` ${task.serviceName}: External image`);
|
||||
} else {
|
||||
logger.logDebug(` ${task.serviceName}: ${task.projectType}`);
|
||||
}
|
||||
});
|
||||
|
||||
return buildTasks;
|
||||
}
|
||||
|
||||
async function performResolution(
|
||||
tasks: MultiBuild.BuildTask[],
|
||||
deviceInfo: DeviceInfo,
|
||||
): Promise<MultiBuild.BuildTask[]> {
|
||||
const { cloneTarStream } = require('tar-utils');
|
||||
|
||||
return await new Promise<MultiBuild.BuildTask[]>((resolve, reject) => {
|
||||
const buildTasks = MultiBuild.performResolution(
|
||||
tasks,
|
||||
deviceInfo.arch,
|
||||
deviceInfo.deviceType,
|
||||
{ error: [reject] },
|
||||
);
|
||||
// Do one task at a time (Bluebird.each instead of Bluebird.all)
|
||||
// in order to reduce peak memory usage. Resolves to buildTasks.
|
||||
Bluebird.each(buildTasks, buildTask => {
|
||||
// buildStream is falsy for "external" tasks (image pull)
|
||||
if (!buildTask.buildStream) {
|
||||
return buildTask;
|
||||
}
|
||||
// Consume each task.buildStream in order to trigger the
|
||||
// resolution events that define fields like:
|
||||
// task.dockerfile, task.dockerfilePath,
|
||||
// task.projectType, task.resolved
|
||||
// This mimics what is currently done in `resin-builder`.
|
||||
return cloneTarStream(buildTask.buildStream).then(
|
||||
(clonedStream: tar.Pack) => {
|
||||
buildTask.buildStream = clonedStream;
|
||||
if (!buildTask.external && !buildTask.resolved) {
|
||||
throw new Error(
|
||||
`Project type for service "${
|
||||
buildTask.serviceName
|
||||
}" could not be determined. Missing a Dockerfile?`,
|
||||
);
|
||||
}
|
||||
return buildTask;
|
||||
},
|
||||
);
|
||||
}).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce that, for example, if 'myProject/MyDockerfile.template' is specified
|
||||
* as an alternativate Dockerfile name, then 'myProject/MyDockerfile' must not
|
||||
* exist.
|
||||
* @param projectPath The project source folder (-s command-line option)
|
||||
* @param dockerfilePath The alternative Dockerfile specified by the user
|
||||
*/
|
||||
export function validateSpecifiedDockerfile(
|
||||
projectPath: string,
|
||||
dockerfilePath: string = '',
|
||||
): string {
|
||||
if (!dockerfilePath) {
|
||||
return dockerfilePath;
|
||||
}
|
||||
const { exitWithExpectedError } = require('../utils/patterns');
|
||||
const { isAbsolute, join, normalize, parse, posix } = require('path');
|
||||
const { existsSync } = require('fs');
|
||||
const { stripIndent } = require('common-tags');
|
||||
const { contains, toNativePath, toPosixPath } = MultiBuild.PathUtils;
|
||||
|
||||
// reminder: native windows paths may start with a drive specificaton,
|
||||
// e.g. 'C:\absolute' or 'C:relative'.
|
||||
if (isAbsolute(dockerfilePath) || posix.isAbsolute(dockerfilePath)) {
|
||||
exitWithExpectedError(stripIndent`
|
||||
Error: absolute Dockerfile path detected:
|
||||
"${dockerfilePath}"
|
||||
The Dockerfile path should be relative to the source folder.
|
||||
`);
|
||||
}
|
||||
const nativeProjectPath = normalize(projectPath);
|
||||
const nativeDockerfilePath = join(projectPath, toNativePath(dockerfilePath));
|
||||
|
||||
if (!contains(nativeProjectPath, nativeDockerfilePath)) {
|
||||
// Note that testing the existence of nativeDockerfilePath in the
|
||||
// filesystem (after joining its path to the source folder) is not
|
||||
// sufficient, because the user could have added '../' to the path.
|
||||
exitWithExpectedError(stripIndent`
|
||||
Error: the specified Dockerfile must be in a subfolder of the source folder:
|
||||
Specified dockerfile: "${nativeDockerfilePath}"
|
||||
Source folder: "${nativeProjectPath}"
|
||||
`);
|
||||
}
|
||||
|
||||
if (!existsSync(nativeDockerfilePath)) {
|
||||
exitWithExpectedError(stripIndent`
|
||||
Error: Dockerfile not found: "${nativeDockerfilePath}"
|
||||
`);
|
||||
}
|
||||
|
||||
const { dir, ext, name } = parse(nativeDockerfilePath);
|
||||
if (ext) {
|
||||
const nativePathMinusExt = join(dir, name);
|
||||
|
||||
if (existsSync(nativePathMinusExt)) {
|
||||
exitWithExpectedError(stripIndent`
|
||||
Error: "${name}" exists on the same folder as "${dockerfilePath}".
|
||||
When an alternative Dockerfile name is specified, a file with the same
|
||||
base name (minus the file extension) must not exist in the same folder.
|
||||
This is because the base name file will be auto generated and added to
|
||||
the tar stream that is sent to the docker daemon, resulting in duplicate
|
||||
Dockerfiles and undefined behavior.
|
||||
`);
|
||||
}
|
||||
}
|
||||
return posix.normalize(toPosixPath(dockerfilePath));
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
exports.generateBaseConfig = (application, options) ->
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
deviceConfig = require('resin-device-config')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
options = _.mapValues options, (value, key) ->
|
||||
if key == 'appUpdatePollInterval'
|
||||
value * 60 * 1000
|
||||
else
|
||||
value
|
||||
|
||||
Promise.props
|
||||
userId: resin.auth.getUserId()
|
||||
username: resin.auth.whoami()
|
||||
apiUrl: resin.settings.get('apiUrl')
|
||||
vpnUrl: resin.settings.get('vpnUrl')
|
||||
registryUrl: resin.settings.get('registryUrl')
|
||||
deltaUrl: resin.settings.get('deltaUrl')
|
||||
pubNubKeys: resin.models.config.getPubNubKeys()
|
||||
mixpanelToken: resin.models.config.getMixpanelToken()
|
||||
.then (results) ->
|
||||
deviceConfig.generate
|
||||
application: application
|
||||
user:
|
||||
id: results.userId
|
||||
username: results.username
|
||||
endpoints:
|
||||
api: results.apiUrl
|
||||
vpn: results.vpnUrl
|
||||
registry: results.registryUrl
|
||||
delta: results.deltaUrl
|
||||
pubnub: results.pubNubKeys
|
||||
mixpanel:
|
||||
token: results.mixpanelToken
|
||||
, options
|
||||
|
||||
exports.generateApplicationConfig = (application, options) ->
|
||||
exports.generateBaseConfig(application, options)
|
||||
.tap (config) ->
|
||||
authenticateWithApplicationKey(config, application.id)
|
||||
|
||||
exports.generateDeviceConfig = (device, deviceApiKey, options) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
resin.models.application.get(device.application_name)
|
||||
.then (application) ->
|
||||
exports.generateBaseConfig(application, options)
|
||||
.tap (config) ->
|
||||
# Device API keys are only safe for ResinOS 2.0.3+. We could somehow obtain
|
||||
# the expected version for this config and generate one when we know it's safe,
|
||||
# but instead for now we fall back to app keys unless the user has explicitly opted in.
|
||||
if deviceApiKey?
|
||||
authenticateWithDeviceKey(config, device.uuid, deviceApiKey)
|
||||
else
|
||||
authenticateWithApplicationKey(config, application.id)
|
||||
.then (config) ->
|
||||
# Associate a device, to prevent the supervisor
|
||||
# from creating another one on its own.
|
||||
config.registered_at = Math.floor(Date.now() / 1000)
|
||||
config.deviceId = device.id
|
||||
config.uuid = device.uuid
|
||||
|
||||
return config
|
||||
|
||||
authenticateWithApplicationKey = (config, applicationNameOrId) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
resin.models.application.generateApiKey(applicationNameOrId)
|
||||
.then (apiKey) ->
|
||||
config.apiKey = apiKey
|
||||
return config
|
||||
|
||||
authenticateWithDeviceKey = (config, uuid, customDeviceApiKey) ->
|
||||
Promise = require('bluebird')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
Promise.try ->
|
||||
customDeviceApiKey || resin.models.device.generateDeviceKey(uuid)
|
||||
.then (deviceApiKey) ->
|
||||
config.deviceApiKey = deviceApiKey
|
||||
return config
|
151
lib/utils/config.ts
Normal file
151
lib/utils/config.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
import BalenaSdk = require('balena-sdk');
|
||||
import Promise = require('bluebird');
|
||||
import * as semver from 'resin-semver';
|
||||
|
||||
const balena = BalenaSdk.fromSharedOptions();
|
||||
|
||||
interface ImgConfig {
|
||||
applicationName: string;
|
||||
applicationId: number;
|
||||
deviceType: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
appUpdatePollInterval: number;
|
||||
listenPort: number;
|
||||
vpnPort: number;
|
||||
apiEndpoint: string;
|
||||
vpnEndpoint: string;
|
||||
registryEndpoint: string;
|
||||
deltaEndpoint: string;
|
||||
mixpanelToken: string;
|
||||
wifiSsid?: string;
|
||||
wifiKey?: string;
|
||||
|
||||
// props for older OS versions
|
||||
connectivity?: string;
|
||||
files?: {
|
||||
[filepath: string]: string;
|
||||
};
|
||||
|
||||
// device specific config props
|
||||
deviceId?: number;
|
||||
uuid?: string;
|
||||
registered_at?: number;
|
||||
}
|
||||
|
||||
export function generateBaseConfig(
|
||||
application: BalenaSdk.Application,
|
||||
options: {
|
||||
version: string;
|
||||
appUpdatePollInterval?: number;
|
||||
deviceType?: string;
|
||||
},
|
||||
): Promise<ImgConfig> {
|
||||
options = {
|
||||
...options,
|
||||
appUpdatePollInterval: options.appUpdatePollInterval || 10,
|
||||
};
|
||||
|
||||
const promise = balena.models.os.getConfig(
|
||||
application.app_name,
|
||||
options,
|
||||
) as Promise<ImgConfig & { apiKey?: string }>;
|
||||
return promise.tap(config => {
|
||||
// os.getConfig always returns a config for an app
|
||||
delete config.apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
export function generateApplicationConfig(
|
||||
application: BalenaSdk.Application,
|
||||
options: { version: string; deviceType?: string },
|
||||
) {
|
||||
return generateBaseConfig(application, options).tap(config => {
|
||||
if (semver.satisfies(options.version, '<2.7.8')) {
|
||||
return addApplicationKey(config, application.id);
|
||||
}
|
||||
return addProvisioningKey(config, application.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function generateDeviceConfig(
|
||||
device: BalenaSdk.Device & {
|
||||
belongs_to__application: BalenaSdk.PineDeferred;
|
||||
},
|
||||
deviceApiKey: string | true | null,
|
||||
options: { version: string },
|
||||
) {
|
||||
return balena.models.application
|
||||
.get(device.belongs_to__application.__id)
|
||||
.then(application => {
|
||||
const baseConfigOpts = {
|
||||
...options,
|
||||
deviceType: device.device_type,
|
||||
};
|
||||
return generateBaseConfig(application, baseConfigOpts).tap(config => {
|
||||
if (
|
||||
deviceApiKey == null &&
|
||||
semver.satisfies(options.version, '<2.0.3')
|
||||
) {
|
||||
return addApplicationKey(config, application.id);
|
||||
}
|
||||
return addDeviceKey(config, device.uuid, deviceApiKey || true);
|
||||
});
|
||||
})
|
||||
.then(config => {
|
||||
// Associate a device, to prevent the supervisor
|
||||
// from creating another one on its own.
|
||||
config.registered_at = Math.floor(Date.now() / 1000);
|
||||
config.deviceId = device.id;
|
||||
config.uuid = device.uuid;
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
function addApplicationKey(config: any, applicationNameOrId: string | number) {
|
||||
return balena.models.application
|
||||
.generateApiKey(applicationNameOrId)
|
||||
.tap(apiKey => {
|
||||
config.apiKey = apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
function addProvisioningKey(config: any, applicationNameOrId: string | number) {
|
||||
return balena.models.application
|
||||
.generateProvisioningKey(applicationNameOrId)
|
||||
.tap(apiKey => {
|
||||
config.apiKey = apiKey;
|
||||
});
|
||||
}
|
||||
|
||||
function addDeviceKey(
|
||||
config: any,
|
||||
uuid: string,
|
||||
customDeviceApiKey: string | true,
|
||||
) {
|
||||
return Promise.try(() => {
|
||||
if (customDeviceApiKey === true) {
|
||||
return balena.models.device.generateDeviceKey(uuid);
|
||||
} else {
|
||||
return customDeviceApiKey;
|
||||
}
|
||||
}).tap(deviceApiKey => {
|
||||
config.deviceApiKey = deviceApiKey;
|
||||
});
|
||||
}
|
136
lib/utils/deploy-legacy.coffee
Normal file
136
lib/utils/deploy-legacy.coffee
Normal file
@ -0,0 +1,136 @@
|
||||
Promise = require('bluebird')
|
||||
|
||||
getBuilderPushEndpoint = (baseUrl, owner, app) ->
|
||||
querystring = require('querystring')
|
||||
args = querystring.stringify({ owner, app })
|
||||
"https://builder.#{baseUrl}/v1/push?#{args}"
|
||||
|
||||
getBuilderLogPushEndpoint = (baseUrl, buildId, owner, app) ->
|
||||
querystring = require('querystring')
|
||||
args = querystring.stringify({ owner, app, buildId })
|
||||
"https://builder.#{baseUrl}/v1/pushLogs?#{args}"
|
||||
|
||||
bufferImage = (docker, imageId, bufferFile) ->
|
||||
Promise = require('bluebird')
|
||||
streamUtils = require('./streams')
|
||||
|
||||
image = docker.getImage(imageId)
|
||||
imageMetadata = image.inspect()
|
||||
|
||||
Promise.join image.get(), imageMetadata.get('Size'), (imageStream, imageSize) ->
|
||||
streamUtils.buffer(imageStream, bufferFile)
|
||||
.tap (bufferedStream) ->
|
||||
bufferedStream.length = imageSize
|
||||
|
||||
showPushProgress = (message) ->
|
||||
visuals = require('resin-cli-visuals')
|
||||
progressBar = new visuals.Progress(message)
|
||||
progressBar.update({ percentage: 0 })
|
||||
return progressBar
|
||||
|
||||
uploadToPromise = (uploadRequest, logger) ->
|
||||
new Promise (resolve, reject) ->
|
||||
|
||||
handleMessage = (data) ->
|
||||
data = data.toString()
|
||||
logger.logDebug("Received data: #{data}")
|
||||
|
||||
try
|
||||
obj = JSON.parse(data)
|
||||
catch e
|
||||
logger.logError('Error parsing reply from remote side')
|
||||
reject(e)
|
||||
return
|
||||
|
||||
switch obj.type
|
||||
when 'error' then reject(new Error("Remote error: #{obj.error}"))
|
||||
when 'success' then resolve(obj)
|
||||
when 'status' then logger.logInfo(obj.message)
|
||||
else reject(new Error("Received unexpected reply from remote: #{data}"))
|
||||
|
||||
uploadRequest
|
||||
.on('error', reject)
|
||||
.on('data', handleMessage)
|
||||
|
||||
uploadImage = (imageStream, token, username, url, appName, logger) ->
|
||||
request = require('request')
|
||||
progressStream = require('progress-stream')
|
||||
zlib = require('zlib')
|
||||
|
||||
# Need to strip off the newline
|
||||
progressMessage = logger.formatMessage('info', 'Uploading').slice(0, -1)
|
||||
progressBar = showPushProgress(progressMessage)
|
||||
streamWithProgress = imageStream.pipe progressStream
|
||||
time: 500,
|
||||
length: imageStream.length
|
||||
, ({ percentage, eta }) ->
|
||||
progressBar.update
|
||||
percentage: Math.min(percentage, 100)
|
||||
eta: eta
|
||||
|
||||
uploadRequest = request.post
|
||||
url: getBuilderPushEndpoint(url, username, appName)
|
||||
headers:
|
||||
'Content-Encoding': 'gzip'
|
||||
auth:
|
||||
bearer: token
|
||||
body: streamWithProgress.pipe(zlib.createGzip({
|
||||
level: 6
|
||||
}))
|
||||
|
||||
uploadToPromise(uploadRequest, logger)
|
||||
|
||||
uploadLogs = (logs, token, url, buildId, username, appName) ->
|
||||
request = require('request')
|
||||
request.post
|
||||
json: true
|
||||
url: getBuilderLogPushEndpoint(url, buildId, username, appName)
|
||||
auth:
|
||||
bearer: token
|
||||
body: Buffer.from(logs)
|
||||
|
||||
###
|
||||
opts must be a hash with the following keys:
|
||||
|
||||
- appName: the name of the app to deploy to
|
||||
- imageName: the name of the image to deploy
|
||||
- buildLogs: a string with build output
|
||||
- shouldUploadLogs
|
||||
###
|
||||
module.exports = (docker, logger, token, username, url, opts) ->
|
||||
tmp = require('tmp')
|
||||
tmpNameAsync = Promise.promisify(tmp.tmpName)
|
||||
|
||||
# Ensure the tmp files gets deleted
|
||||
tmp.setGracefulCleanup()
|
||||
|
||||
{ appName, imageName, buildLogs, shouldUploadLogs } = opts
|
||||
logs = buildLogs
|
||||
|
||||
tmpNameAsync()
|
||||
.then (bufferFile) ->
|
||||
logger.logInfo('Initializing deploy...')
|
||||
bufferImage(docker, imageName, bufferFile)
|
||||
.then (stream) ->
|
||||
uploadImage(stream, token, username, url, appName, logger)
|
||||
.finally ->
|
||||
# If the file was never written to (for instance because an error
|
||||
# has occured before any data was written) this call will throw an
|
||||
# ugly error, just suppress it
|
||||
Promise.try ->
|
||||
require('mz/fs').unlink(bufferFile)
|
||||
.catchReturn()
|
||||
.tap ({ buildId }) ->
|
||||
return if not shouldUploadLogs
|
||||
|
||||
logger.logInfo('Uploading logs...')
|
||||
Promise.join(
|
||||
logs
|
||||
token
|
||||
url
|
||||
buildId
|
||||
username
|
||||
appName
|
||||
uploadLogs
|
||||
)
|
||||
.get('buildId')
|
258
lib/utils/device/api.ts
Normal file
258
lib/utils/device/api.ts
Normal file
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @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 Bluebird from 'bluebird';
|
||||
import * as _ from 'lodash';
|
||||
import { NodeJSSocketWithFileDescriptor } from 'net-keepalive';
|
||||
import * as os from 'os';
|
||||
import * as request from 'request';
|
||||
import * as Stream from 'stream';
|
||||
|
||||
import Logger = require('../logger');
|
||||
import * as ApiErrors from './errors';
|
||||
|
||||
export interface DeviceResponse {
|
||||
[key: string]: any;
|
||||
|
||||
status: 'success' | 'failed';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceType: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
appState: 'applied' | 'applying';
|
||||
overallDownloadProgress: null | number;
|
||||
containers: Array<{
|
||||
status: string;
|
||||
serviceName: string;
|
||||
appId: number;
|
||||
imageId: number;
|
||||
serviceId: number;
|
||||
containerId: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
images: Array<{
|
||||
name: string;
|
||||
appId: number;
|
||||
serviceName: string;
|
||||
imageId: number;
|
||||
dockerImageId: string;
|
||||
status: string;
|
||||
downloadProgress: null | number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const deviceEndpoints = {
|
||||
setTargetState: 'v2/local/target-state',
|
||||
getTargetState: 'v2/local/target-state',
|
||||
getDeviceInformation: 'v2/local/device-info',
|
||||
logs: 'v2/local/logs',
|
||||
ping: 'ping',
|
||||
version: 'v2/version',
|
||||
status: 'v2/state/status',
|
||||
};
|
||||
|
||||
export class DeviceAPI {
|
||||
private deviceAddress: string;
|
||||
|
||||
public constructor(
|
||||
private logger: Logger,
|
||||
addr: string,
|
||||
port: number = 48484,
|
||||
) {
|
||||
this.deviceAddress = `http://${addr}:${port}/`;
|
||||
}
|
||||
|
||||
// Either return nothing, or throw an error with the info
|
||||
public async setTargetState(state: any): Promise<void> {
|
||||
const url = this.getUrlForAction('setTargetState');
|
||||
return DeviceAPI.promisifiedRequest(
|
||||
request.post,
|
||||
{
|
||||
url,
|
||||
json: true,
|
||||
body: state,
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
public async getTargetState(): Promise<any> {
|
||||
const url = this.getUrlForAction('getTargetState');
|
||||
|
||||
return DeviceAPI.promisifiedRequest(
|
||||
request.get,
|
||||
{
|
||||
url,
|
||||
json: true,
|
||||
},
|
||||
this.logger,
|
||||
).then(body => {
|
||||
return body.state;
|
||||
});
|
||||
}
|
||||
|
||||
public async getDeviceInformation(): Promise<DeviceInfo> {
|
||||
const url = this.getUrlForAction('getDeviceInformation');
|
||||
|
||||
return DeviceAPI.promisifiedRequest(
|
||||
request.get,
|
||||
{
|
||||
url,
|
||||
json: true,
|
||||
},
|
||||
this.logger,
|
||||
).then(body => {
|
||||
return body.info;
|
||||
});
|
||||
}
|
||||
|
||||
public async ping(): Promise<void> {
|
||||
const url = this.getUrlForAction('ping');
|
||||
|
||||
return DeviceAPI.promisifiedRequest(
|
||||
request.get,
|
||||
{
|
||||
url,
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
public getVersion(): Promise<string> {
|
||||
const url = this.getUrlForAction('version');
|
||||
|
||||
return DeviceAPI.promisifiedRequest(request.get, {
|
||||
url,
|
||||
json: true,
|
||||
}).then(body => {
|
||||
if (body.status !== 'success') {
|
||||
throw new ApiErrors.DeviceAPIError(
|
||||
'Non-successful response from supervisor version endpoint',
|
||||
);
|
||||
}
|
||||
|
||||
return body.version;
|
||||
});
|
||||
}
|
||||
|
||||
public getStatus(): Promise<Status> {
|
||||
const url = this.getUrlForAction('status');
|
||||
|
||||
return DeviceAPI.promisifiedRequest(request.get, {
|
||||
url,
|
||||
json: true,
|
||||
}).then(body => {
|
||||
if (body.status !== 'success') {
|
||||
throw new ApiErrors.DeviceAPIError(
|
||||
'Non-successful response from supervisor status endpoint',
|
||||
);
|
||||
}
|
||||
|
||||
return _.omit(body, 'status') as Status;
|
||||
});
|
||||
}
|
||||
|
||||
public getLogStream(): Bluebird<Stream.Readable> {
|
||||
const url = this.getUrlForAction('logs');
|
||||
|
||||
// Don't use the promisified version here as we want to stream the output
|
||||
return new Bluebird((resolve, reject) => {
|
||||
const req = request.get(url);
|
||||
|
||||
req.on('error', reject).on('response', async res => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(
|
||||
new ApiErrors.DeviceAPIError(
|
||||
'Non-200 response from log streaming endpoint',
|
||||
),
|
||||
);
|
||||
}
|
||||
res.socket.setKeepAlive(true, 1000);
|
||||
if (os.platform() !== 'win32') {
|
||||
const NetKeepalive = await import('net-keepalive');
|
||||
// Certain versions of typescript won't convert
|
||||
// this automatically
|
||||
const sock = (res.socket as any) as NodeJSSocketWithFileDescriptor;
|
||||
// We send a tcp keepalive probe once every 5 seconds
|
||||
NetKeepalive.setKeepAliveInterval(sock, 5000);
|
||||
// After 5 failed probes, the connection is marked as
|
||||
// closed
|
||||
NetKeepalive.setKeepAliveProbes(sock, 5);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getUrlForAction(action: keyof typeof deviceEndpoints): string {
|
||||
return `${this.deviceAddress}${deviceEndpoints[action]}`;
|
||||
}
|
||||
|
||||
// A helper method for promisifying general (non-streaming) requests. Streaming
|
||||
// requests should use a seperate setup
|
||||
private static async promisifiedRequest<T>(
|
||||
requestMethod: (
|
||||
opts: T,
|
||||
cb: (err?: any, res?: any, body?: any) => void,
|
||||
) => void,
|
||||
opts: T,
|
||||
logger?: Logger,
|
||||
): Promise<any> {
|
||||
interface ObjectWithUrl {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
if (logger != null) {
|
||||
let url: string | null = null;
|
||||
if (_.isObject(opts) && (opts as ObjectWithUrl).url != null) {
|
||||
// the `as string` shouldn't be necessary, but the type system
|
||||
// is getting a little confused
|
||||
url = (opts as ObjectWithUrl).url as string;
|
||||
} else if (_.isString(opts)) {
|
||||
url = opts;
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
logger.logDebug(`Sending request to ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Bluebird.fromCallback<[request.Response, { message: string }]>(
|
||||
cb => {
|
||||
return requestMethod(opts, cb);
|
||||
},
|
||||
{ multiArgs: true },
|
||||
).then(([response, body]) => {
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return body;
|
||||
case 400:
|
||||
throw new ApiErrors.BadRequestDeviceAPIError(body.message);
|
||||
case 503:
|
||||
throw new ApiErrors.ServiceUnavailableAPIError(body.message);
|
||||
default:
|
||||
throw new ApiErrors.DeviceAPIError(body.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default DeviceAPI;
|
437
lib/utils/device/deploy.ts
Normal file
437
lib/utils/device/deploy.ts
Normal file
@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 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 Bluebird from 'bluebird';
|
||||
import * as Docker from 'dockerode';
|
||||
import * as _ from 'lodash';
|
||||
import { Composition } from 'resin-compose-parse';
|
||||
import {
|
||||
BuildTask,
|
||||
getAuthConfigObj,
|
||||
LocalImage,
|
||||
RegistrySecrets,
|
||||
} from 'resin-multibuild';
|
||||
import * as semver from 'resin-semver';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { makeBuildTasks } from '../compose_ts';
|
||||
import Logger = require('../logger');
|
||||
import { DeviceAPI, DeviceInfo } from './api';
|
||||
import * as LocalPushErrors from './errors';
|
||||
import LivepushManager from './live';
|
||||
import { displayBuildLog } from './logs';
|
||||
|
||||
// Define the logger here so the debug output
|
||||
// can be used everywhere
|
||||
const globalLogger = new Logger();
|
||||
|
||||
export interface DeviceDeployOptions {
|
||||
source: string;
|
||||
deviceHost: string;
|
||||
devicePort?: number;
|
||||
dockerfilePath?: string;
|
||||
registrySecrets: RegistrySecrets;
|
||||
nocache: boolean;
|
||||
live: boolean;
|
||||
detached: boolean;
|
||||
service?: string;
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
async function checkSource(source: string): Promise<boolean> {
|
||||
const { fs } = await import('mz');
|
||||
return (await fs.exists(source)) && (await fs.stat(source)).isDirectory();
|
||||
}
|
||||
|
||||
export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
|
||||
const { loadProject, tarDirectory } = await import('../compose');
|
||||
const { exitWithExpectedError } = await import('../patterns');
|
||||
|
||||
const { displayDeviceLogs } = await import('./logs');
|
||||
|
||||
if (!(await checkSource(opts.source))) {
|
||||
exitWithExpectedError(`Could not access source directory: ${opts.source}`);
|
||||
}
|
||||
|
||||
const api = new DeviceAPI(globalLogger, opts.deviceHost);
|
||||
|
||||
// First check that we can access the device with a ping
|
||||
try {
|
||||
globalLogger.logDebug('Checking we can access device');
|
||||
await api.ping();
|
||||
} catch (e) {
|
||||
exitWithExpectedError(
|
||||
`Could not communicate with local mode device at address ${
|
||||
opts.deviceHost
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionError = new Error(
|
||||
'The supervisor version on this remote device does not support multicontainer local mode. ' +
|
||||
'Please update your device to balenaOS v2.20.0 or greater from the dashboard.',
|
||||
);
|
||||
|
||||
try {
|
||||
const version = await api.getVersion();
|
||||
globalLogger.logDebug(`Checking device version: ${version}`);
|
||||
if (!semver.satisfies(version, '>=7.21.4')) {
|
||||
exitWithExpectedError(versionError);
|
||||
}
|
||||
if (opts.live && !semver.satisfies(version, '>=9.7.0')) {
|
||||
exitWithExpectedError(
|
||||
new Error('Using livepush requires a supervisor >= v9.7.0'),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
exitWithExpectedError(versionError);
|
||||
}
|
||||
|
||||
globalLogger.logInfo(`Starting build on device ${opts.deviceHost}`);
|
||||
|
||||
const project = await loadProject(
|
||||
globalLogger,
|
||||
opts.source, // project path
|
||||
'local', // project name
|
||||
undefined, // name of a pre-built image
|
||||
opts.dockerfilePath, // alternative Dockerfile; OK to be undefined
|
||||
);
|
||||
|
||||
// Attempt to attach to the device's docker daemon
|
||||
const docker = connectToDocker(
|
||||
opts.deviceHost,
|
||||
opts.devicePort != null ? opts.devicePort : 2375,
|
||||
);
|
||||
|
||||
const tarStream = await tarDirectory(opts.source);
|
||||
|
||||
// Try to detect the device information
|
||||
const deviceInfo = await api.getDeviceInformation();
|
||||
|
||||
let buildLogs: Dictionary<string> | undefined;
|
||||
if (opts.live) {
|
||||
buildLogs = {};
|
||||
}
|
||||
const buildTasks = await performBuilds(
|
||||
project.composition,
|
||||
tarStream,
|
||||
docker,
|
||||
deviceInfo,
|
||||
globalLogger,
|
||||
opts,
|
||||
buildLogs,
|
||||
);
|
||||
|
||||
globalLogger.logDebug('Setting device state...');
|
||||
// Now set the target state on the device
|
||||
|
||||
const currentTargetState = await api.getTargetState();
|
||||
|
||||
const targetState = generateTargetState(
|
||||
currentTargetState,
|
||||
project.composition,
|
||||
);
|
||||
globalLogger.logDebug(`Sending target state: ${JSON.stringify(targetState)}`);
|
||||
|
||||
await api.setTargetState(targetState);
|
||||
|
||||
// Now that we've set the target state, the device will do it's thing
|
||||
// so we can either just display the logs, or start a livepush session
|
||||
// (whilst also display logs)
|
||||
if (opts.live) {
|
||||
const livepush = new LivepushManager({
|
||||
api,
|
||||
buildContext: opts.source,
|
||||
buildTasks,
|
||||
docker,
|
||||
logger: globalLogger,
|
||||
composition: project.composition,
|
||||
buildLogs: buildLogs!,
|
||||
deployOpts: opts,
|
||||
});
|
||||
|
||||
const promises = [livepush.init()];
|
||||
// Only show logs if we're not detaching
|
||||
if (!opts.detached) {
|
||||
console.log();
|
||||
const logStream = await api.getLogStream();
|
||||
globalLogger.logInfo('Streaming device logs...');
|
||||
promises.push(
|
||||
displayDeviceLogs(logStream, globalLogger, opts.system, opts.service),
|
||||
);
|
||||
} else {
|
||||
globalLogger.logLivepush(
|
||||
'Running in detached mode, no service logs will be shown',
|
||||
);
|
||||
}
|
||||
globalLogger.logLivepush('Watching for file changes...');
|
||||
await Promise.all(promises);
|
||||
} else {
|
||||
if (opts.detached) {
|
||||
return;
|
||||
}
|
||||
// Print an empty newline to separate the build output
|
||||
// from the device output
|
||||
console.log();
|
||||
// Now all we need to do is stream back the logs
|
||||
const logStream = await api.getLogStream();
|
||||
globalLogger.logInfo('Streaming device logs...');
|
||||
await displayDeviceLogs(logStream, globalLogger, opts.system, opts.service);
|
||||
}
|
||||
}
|
||||
|
||||
function connectToDocker(host: string, port: number): Docker {
|
||||
return new Docker({
|
||||
host,
|
||||
port,
|
||||
Promise: Bluebird as any,
|
||||
});
|
||||
}
|
||||
|
||||
export async function performBuilds(
|
||||
composition: Composition,
|
||||
tarStream: Readable,
|
||||
docker: Docker,
|
||||
deviceInfo: DeviceInfo,
|
||||
logger: Logger,
|
||||
opts: DeviceDeployOptions,
|
||||
buildLogs?: Dictionary<string>,
|
||||
): Promise<BuildTask[]> {
|
||||
const multibuild = await import('resin-multibuild');
|
||||
|
||||
const buildTasks = await makeBuildTasks(
|
||||
composition,
|
||||
tarStream,
|
||||
deviceInfo,
|
||||
logger,
|
||||
);
|
||||
|
||||
logger.logDebug('Probing remote daemon for cache images');
|
||||
await assignDockerBuildOpts(docker, buildTasks, opts);
|
||||
|
||||
logger.logDebug('Starting builds...');
|
||||
await assignOutputHandlers(buildTasks, logger, buildLogs);
|
||||
const localImages = await multibuild.performBuilds(buildTasks, docker);
|
||||
|
||||
// Check for failures
|
||||
await inspectBuildResults(localImages);
|
||||
|
||||
// Now tag any external images with the correct name that they should be,
|
||||
// as this won't be done by resin-multibuild
|
||||
await Bluebird.map(localImages, async localImage => {
|
||||
if (localImage.external) {
|
||||
// We can be sure that localImage.name is set here, because of the failure code above
|
||||
const image = docker.getImage(localImage.name!);
|
||||
await image.tag({
|
||||
repo: generateImageName(localImage.serviceName),
|
||||
force: true,
|
||||
});
|
||||
await image.remove({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return buildTasks;
|
||||
}
|
||||
|
||||
// Rebuild a single container, execute it on device, and
|
||||
// return the build logs
|
||||
export async function rebuildSingleTask(
|
||||
serviceName: string,
|
||||
docker: Docker,
|
||||
logger: Logger,
|
||||
deviceInfo: DeviceInfo,
|
||||
composition: Composition,
|
||||
source: string,
|
||||
opts: DeviceDeployOptions,
|
||||
): Promise<string> {
|
||||
const { tarDirectory } = await import('../compose');
|
||||
const multibuild = await import('resin-multibuild');
|
||||
// First we run the build task, to get the new image id
|
||||
const buildLogs: Dictionary<string> = {};
|
||||
|
||||
const tarStream = await tarDirectory(source);
|
||||
|
||||
const task = _.find(
|
||||
await makeBuildTasks(composition, tarStream, deviceInfo, logger),
|
||||
{ serviceName },
|
||||
);
|
||||
|
||||
if (task == null) {
|
||||
throw new Error(`Could not find build task for service ${serviceName}`);
|
||||
}
|
||||
|
||||
await assignDockerBuildOpts(docker, [task], opts);
|
||||
await assignOutputHandlers([task], logger, buildLogs);
|
||||
|
||||
const [localImage] = await multibuild.performBuilds([task], docker);
|
||||
|
||||
if (!localImage.successful) {
|
||||
throw new LocalPushErrors.BuildError([
|
||||
{
|
||||
error: localImage.error!,
|
||||
serviceName,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return buildLogs[task.serviceName];
|
||||
}
|
||||
|
||||
function assignOutputHandlers(
|
||||
buildTasks: BuildTask[],
|
||||
logger: Logger,
|
||||
buildLogs?: Dictionary<string>,
|
||||
) {
|
||||
_.each(buildTasks, task => {
|
||||
if (task.external) {
|
||||
task.progressHook = progressObj => {
|
||||
displayBuildLog(
|
||||
{ serviceName: task.serviceName, message: progressObj.progress },
|
||||
logger,
|
||||
);
|
||||
};
|
||||
} else {
|
||||
if (buildLogs) {
|
||||
buildLogs[task.serviceName] = '';
|
||||
}
|
||||
task.streamHook = stream => {
|
||||
stream.on('data', (buf: Buffer) => {
|
||||
const str = _.trimEnd(buf.toString());
|
||||
if (str !== '') {
|
||||
displayBuildLog(
|
||||
{ serviceName: task.serviceName, message: str },
|
||||
logger,
|
||||
);
|
||||
|
||||
if (buildLogs) {
|
||||
buildLogs[task.serviceName] = `${
|
||||
buildLogs[task.serviceName]
|
||||
}\n${str}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getDeviceDockerImages(docker: Docker): Promise<string[]> {
|
||||
const images = await docker.listImages();
|
||||
|
||||
return _.map(images, 'Id');
|
||||
}
|
||||
|
||||
// Mutates buildTasks
|
||||
async function assignDockerBuildOpts(
|
||||
docker: Docker,
|
||||
buildTasks: BuildTask[],
|
||||
opts: DeviceDeployOptions,
|
||||
): Promise<void> {
|
||||
// Get all of the images on the remote docker daemon, so
|
||||
// that we can use all of them for cache
|
||||
const images = await getDeviceDockerImages(docker);
|
||||
|
||||
globalLogger.logDebug(`Using ${images.length} on-device images for cache...`);
|
||||
|
||||
await Bluebird.map(buildTasks, async (task: BuildTask) => {
|
||||
task.dockerOpts = {
|
||||
cachefrom: images,
|
||||
labels: {
|
||||
'io.resin.local.image': '1',
|
||||
'io.resin.local.service': task.serviceName,
|
||||
},
|
||||
t: generateImageName(task.serviceName),
|
||||
nocache: opts.nocache,
|
||||
forcerm: true,
|
||||
};
|
||||
if (task.external) {
|
||||
task.dockerOpts.authconfig = await getAuthConfigObj(
|
||||
task.imageName!,
|
||||
opts.registrySecrets,
|
||||
);
|
||||
} else {
|
||||
task.dockerOpts.registryconfig = opts.registrySecrets;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateImageName(serviceName: string): string {
|
||||
return `local_image_${serviceName}:latest`;
|
||||
}
|
||||
|
||||
export function generateTargetState(
|
||||
currentTargetState: any,
|
||||
composition: Composition,
|
||||
): any {
|
||||
const services: { [serviceId: string]: any } = {};
|
||||
let idx = 1;
|
||||
_.each(composition.services, (opts, name) => {
|
||||
// Get rid of any build specific stuff
|
||||
opts = _.cloneDeep(opts);
|
||||
delete opts.build;
|
||||
delete opts.image;
|
||||
|
||||
const defaults = {
|
||||
environment: {},
|
||||
labels: {},
|
||||
};
|
||||
|
||||
services[idx] = _.merge(defaults, opts, {
|
||||
imageId: idx,
|
||||
serviceName: name,
|
||||
serviceId: idx,
|
||||
image: generateImageName(name),
|
||||
running: true,
|
||||
});
|
||||
idx += 1;
|
||||
});
|
||||
|
||||
const targetState = _.cloneDeep(currentTargetState);
|
||||
delete targetState.local.apps;
|
||||
|
||||
targetState.local.apps = {
|
||||
1: {
|
||||
name: 'localapp',
|
||||
commit: 'localrelease',
|
||||
releaseId: '1',
|
||||
services,
|
||||
volumes: composition.volumes || {},
|
||||
networks: composition.networks || {},
|
||||
},
|
||||
};
|
||||
|
||||
return targetState;
|
||||
}
|
||||
|
||||
async function inspectBuildResults(images: LocalImage[]): Promise<void> {
|
||||
const { exitWithExpectedError } = await import('../patterns');
|
||||
|
||||
const failures: LocalPushErrors.BuildFailure[] = [];
|
||||
|
||||
_.each(images, image => {
|
||||
if (!image.successful) {
|
||||
failures.push({
|
||||
error: image.error!,
|
||||
serviceName: image.serviceName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
exitWithExpectedError(new LocalPushErrors.BuildError(failures).toString());
|
||||
}
|
||||
}
|
39
lib/utils/device/errors.ts
Normal file
39
lib/utils/device/errors.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import * as _ from 'lodash';
|
||||
import { TypedError } from 'typed-error';
|
||||
|
||||
export interface BuildFailure {
|
||||
error: Error;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export class BuildError extends TypedError {
|
||||
private failures: BuildFailure[];
|
||||
|
||||
public constructor(failures: BuildFailure[]) {
|
||||
super('Build error');
|
||||
|
||||
this.failures = failures;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
let str = 'Some services failed to build:\n';
|
||||
_.each(this.failures, failure => {
|
||||
str += `\t${failure.serviceName}: ${failure.error.message}\n`;
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
public getServiceError(serviceName: string): string {
|
||||
const failure = _.find(this.failures, f => f.serviceName === serviceName);
|
||||
if (failure == null) {
|
||||
return 'Unknown build failure';
|
||||
}
|
||||
|
||||
return failure.error.message;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceAPIError extends TypedError {}
|
||||
|
||||
export class BadRequestDeviceAPIError extends DeviceAPIError {}
|
||||
export class ServiceUnavailableAPIError extends DeviceAPIError {}
|
426
lib/utils/device/live.ts
Normal file
426
lib/utils/device/live.ts
Normal file
@ -0,0 +1,426 @@
|
||||
import * as Bluebird from 'bluebird';
|
||||
import * as chokidar from 'chokidar';
|
||||
import * as Dockerode from 'dockerode';
|
||||
import Livepush, {
|
||||
ContainerNotRunningError,
|
||||
LivepushAlreadyRunningError,
|
||||
} from 'livepush';
|
||||
import * as _ from 'lodash';
|
||||
import * as path from 'path';
|
||||
import { Composition } from 'resin-compose-parse';
|
||||
import { BuildTask } from 'resin-multibuild';
|
||||
|
||||
import Logger = require('../logger');
|
||||
|
||||
import DeviceAPI, { DeviceInfo, Status } from './api';
|
||||
import {
|
||||
DeviceDeployOptions,
|
||||
generateTargetState,
|
||||
rebuildSingleTask,
|
||||
} from './deploy';
|
||||
import { BuildError } from './errors';
|
||||
import { getServiceColourFn } from './logs';
|
||||
|
||||
// How often do we want to check the device state
|
||||
// engine has settled (delay in ms)
|
||||
const DEVICE_STATUS_SETTLE_CHECK_INTERVAL = 1000;
|
||||
|
||||
const LIVEPUSH_DEBOUNCE_TIMEOUT = 2000;
|
||||
|
||||
interface MonitoredContainer {
|
||||
context: string;
|
||||
livepush: Livepush;
|
||||
monitor: chokidar.FSWatcher;
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
type BuildLogs = Dictionary<string>;
|
||||
type StageImageIDs = Dictionary<string[]>;
|
||||
|
||||
export interface LivepushOpts {
|
||||
buildContext: string;
|
||||
composition: Composition;
|
||||
buildTasks: BuildTask[];
|
||||
docker: Dockerode;
|
||||
api: DeviceAPI;
|
||||
logger: Logger;
|
||||
buildLogs: BuildLogs;
|
||||
deployOpts: DeviceDeployOptions;
|
||||
}
|
||||
|
||||
export class LivepushManager {
|
||||
private lastDeviceStatus: Status | null = null;
|
||||
private containers: Dictionary<MonitoredContainer> = {};
|
||||
private dockerfilePaths: Dictionary<string[]> = {};
|
||||
private deviceInfo: DeviceInfo;
|
||||
private deployOpts: DeviceDeployOptions;
|
||||
|
||||
private buildContext: string;
|
||||
private composition: Composition;
|
||||
private buildTasks: BuildTask[];
|
||||
private docker: Dockerode;
|
||||
private api: DeviceAPI;
|
||||
private logger: Logger;
|
||||
private imageIds: StageImageIDs;
|
||||
|
||||
// A map of service names to events waiting
|
||||
private updateEventsWaiting: Dictionary<string[]> = {};
|
||||
private deleteEventsWaiting: Dictionary<string[]> = {};
|
||||
|
||||
public constructor(opts: LivepushOpts) {
|
||||
this.buildContext = opts.buildContext;
|
||||
this.composition = opts.composition;
|
||||
this.buildTasks = opts.buildTasks;
|
||||
this.docker = opts.docker;
|
||||
this.api = opts.api;
|
||||
this.logger = opts.logger;
|
||||
this.deployOpts = opts.deployOpts;
|
||||
this.imageIds = LivepushManager.getMultistageImageIDs(opts.buildLogs);
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.deviceInfo = await this.api.getDeviceInformation();
|
||||
this.logger.logLivepush('Waiting for device state to settle...');
|
||||
// The first thing we need to do is let the state 'settle',
|
||||
// so that all of the containers are running and ready to
|
||||
// be livepush'd into
|
||||
await this.awaitDeviceStateSettle();
|
||||
// Split the composition into a load of differents paths
|
||||
// which we can
|
||||
this.logger.logLivepush('Device state settled');
|
||||
// create livepush instances for
|
||||
|
||||
for (const serviceName of _.keys(this.composition.services)) {
|
||||
const service = this.composition.services[serviceName];
|
||||
const buildTask = _.find(this.buildTasks, { serviceName });
|
||||
|
||||
if (buildTask == null) {
|
||||
throw new Error(
|
||||
`Could not find a build task for service: ${serviceName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// We only care about builds
|
||||
if (service.build != null) {
|
||||
const context = path.join(this.buildContext, service.build.context);
|
||||
const dockerfile = buildTask.dockerfile;
|
||||
if (dockerfile == null) {
|
||||
throw new Error(
|
||||
`Could not detect dockerfile for service: ${serviceName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (buildTask.dockerfilePath == null) {
|
||||
// this is a bit of a hack as resin-bundle-resolve
|
||||
// does not always export the dockerfilePath, this
|
||||
// only happens when the dockerfile path is
|
||||
// specified differently - this should be patched
|
||||
// in resin-bundle-resolve
|
||||
this.dockerfilePaths[
|
||||
buildTask.serviceName
|
||||
] = this.getDockerfilePathFromTask(buildTask);
|
||||
} else {
|
||||
this.dockerfilePaths[buildTask.serviceName] = [
|
||||
buildTask.dockerfilePath,
|
||||
];
|
||||
}
|
||||
|
||||
// Find the containerId from the device state
|
||||
const container = _.find(this.lastDeviceStatus!.containers, {
|
||||
serviceName,
|
||||
});
|
||||
if (container == null) {
|
||||
throw new Error(
|
||||
`Could not find a container on device for service: ${serviceName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const msgString = (msg: string) =>
|
||||
`[${getServiceColourFn(serviceName)(serviceName)}] ${msg}`;
|
||||
const log = (msg: string) => this.logger.logLivepush(msgString(msg));
|
||||
const error = (msg: string) => this.logger.logError(msgString(msg));
|
||||
const debugLog = (msg: string) => this.logger.logDebug(msgString(msg));
|
||||
|
||||
const livepush = await Livepush.init(
|
||||
dockerfile,
|
||||
context,
|
||||
container.containerId,
|
||||
this.imageIds[serviceName],
|
||||
this.docker,
|
||||
);
|
||||
|
||||
livepush.on('commandExecute', command =>
|
||||
log(`Executing command: \`${command.command}\``),
|
||||
);
|
||||
livepush.on('commandOutput', output =>
|
||||
log(` ${output.output.data.toString()}`),
|
||||
);
|
||||
livepush.on('commandReturn', ({ returnCode, command }) => {
|
||||
if (returnCode !== 0) {
|
||||
error(` Command ${command} failed with exit code: ${returnCode}`);
|
||||
} else {
|
||||
debugLog(`Command ${command} exited successfully`);
|
||||
}
|
||||
});
|
||||
livepush.on('containerRestart', () => {
|
||||
log('Restarting service...');
|
||||
});
|
||||
|
||||
this.updateEventsWaiting[serviceName] = [];
|
||||
this.deleteEventsWaiting[serviceName] = [];
|
||||
const addEvent = (eventQueue: string[], changedPath: string) => {
|
||||
this.logger.logDebug(
|
||||
`Got an add filesystem event for service: ${serviceName}. File: ${changedPath}`,
|
||||
);
|
||||
eventQueue.push(changedPath);
|
||||
this.getDebouncedEventHandler(serviceName)();
|
||||
};
|
||||
// TODO: Memoize this for containers which share a context
|
||||
const monitor = chokidar.watch('.', {
|
||||
cwd: context,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
monitor.on('add', (changedPath: string) =>
|
||||
addEvent(this.updateEventsWaiting[serviceName], changedPath),
|
||||
);
|
||||
monitor.on('change', (changedPath: string) =>
|
||||
addEvent(this.updateEventsWaiting[serviceName], changedPath),
|
||||
);
|
||||
monitor.on('unlink', (changedPath: string) =>
|
||||
addEvent(this.deleteEventsWaiting[serviceName], changedPath),
|
||||
);
|
||||
|
||||
this.containers[serviceName] = {
|
||||
livepush,
|
||||
context,
|
||||
monitor,
|
||||
containerId: container.containerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Setup cleanup handlers for the device
|
||||
|
||||
// This is necessary because the `exit-hook` module is used by several
|
||||
// dependencies, and will exit without calling the following handler.
|
||||
// Once https://github.com/balena-io/balena-cli/issues/867 has been solved,
|
||||
// we are free to (and definitely should) remove the below line
|
||||
process.removeAllListeners('SIGINT');
|
||||
process.on('SIGINT', async () => {
|
||||
this.logger.logLivepush('Cleaning up device...');
|
||||
await Promise.all(
|
||||
_.map(this.containers, container => {
|
||||
container.livepush.cleanupIntermediateContainers();
|
||||
}),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
private static getMultistageImageIDs(buildLogs: BuildLogs): StageImageIDs {
|
||||
const stageIds: StageImageIDs = {};
|
||||
_.each(buildLogs, (log, serviceName) => {
|
||||
stageIds[serviceName] = [];
|
||||
|
||||
const lines = log.split(/\r?\n/);
|
||||
let lastArrowMessage: string | undefined;
|
||||
for (const line of lines) {
|
||||
// If this was a from line, take the last found
|
||||
// image id and save it
|
||||
if (
|
||||
/step \d+(?:\/\d+)?\s*:\s*FROM/i.test(line) &&
|
||||
lastArrowMessage != null
|
||||
) {
|
||||
stageIds[serviceName].push(lastArrowMessage);
|
||||
} else {
|
||||
const msg = LivepushManager.extractDockerArrowMessage(line);
|
||||
if (msg != null) {
|
||||
lastArrowMessage = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return stageIds;
|
||||
}
|
||||
|
||||
private async awaitDeviceStateSettle(): Promise<void> {
|
||||
// Cache the state to avoid unnecessary cals
|
||||
this.lastDeviceStatus = await this.api.getStatus();
|
||||
|
||||
if (this.lastDeviceStatus.appState === 'applied') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.logDebug(
|
||||
`Device state not settled, retrying in ${DEVICE_STATUS_SETTLE_CHECK_INTERVAL}ms`,
|
||||
);
|
||||
await Bluebird.delay(DEVICE_STATUS_SETTLE_CHECK_INTERVAL);
|
||||
await this.awaitDeviceStateSettle();
|
||||
}
|
||||
|
||||
private async handleFSEvents(serviceName: string): Promise<void> {
|
||||
const updated = this.updateEventsWaiting[serviceName];
|
||||
const deleted = this.deleteEventsWaiting[serviceName];
|
||||
this.updateEventsWaiting[serviceName] = [];
|
||||
this.deleteEventsWaiting[serviceName] = [];
|
||||
|
||||
// First we detect if the file changed is the Dockerfile
|
||||
// used to build the service
|
||||
if (
|
||||
_.some(this.dockerfilePaths[serviceName], name =>
|
||||
_.some(updated, changed => name === changed),
|
||||
)
|
||||
) {
|
||||
this.logger.logLivepush(
|
||||
`Detected Dockerfile change, performing full rebuild of service ${serviceName}`,
|
||||
);
|
||||
await this.handleServiceRebuild(serviceName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work out if we need to perform any changes on this container
|
||||
const livepush = this.containers[serviceName].livepush;
|
||||
|
||||
this.logger.logLivepush(
|
||||
`Detected changes for container ${serviceName}, updating...`,
|
||||
);
|
||||
|
||||
try {
|
||||
await livepush.performLivepush(updated, deleted);
|
||||
} catch (e) {
|
||||
this.logger.logError(
|
||||
`An error occured whilst trying to perform a livepush: `,
|
||||
);
|
||||
if (e instanceof LivepushAlreadyRunningError) {
|
||||
this.logger.logError(' Livepush already running');
|
||||
} else if (e instanceof ContainerNotRunningError) {
|
||||
this.logger.logError(' Livepush container not running');
|
||||
} else {
|
||||
this.logger.logError(` ${e.message}`);
|
||||
}
|
||||
this.logger.logDebug(e.stack);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleServiceRebuild(serviceName: string): Promise<void> {
|
||||
try {
|
||||
const buildTask = _.find(this.buildTasks, { serviceName });
|
||||
if (buildTask == null) {
|
||||
throw new Error(
|
||||
`Could not find a build task for service ${serviceName}`,
|
||||
);
|
||||
}
|
||||
|
||||
let buildLog: string;
|
||||
try {
|
||||
buildLog = await rebuildSingleTask(
|
||||
serviceName,
|
||||
this.docker,
|
||||
this.logger,
|
||||
this.deviceInfo,
|
||||
this.composition,
|
||||
this.buildContext,
|
||||
this.deployOpts,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BuildError)) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.logger.logError(
|
||||
`Rebuild of service ${serviceName} failed!\n Error: ${e.getServiceError(
|
||||
serviceName,
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: The code below is quite roundabout, and instead
|
||||
// we'd prefer just to call a supervisor endpoint which
|
||||
// recreates a container, but that doesn't exist yet
|
||||
|
||||
// First we request the current target state
|
||||
const currentState = await this.api.getTargetState();
|
||||
|
||||
// Then we generate a target state without the service
|
||||
// we rebuilt
|
||||
const comp = _.cloneDeep(this.composition);
|
||||
delete comp.services[serviceName];
|
||||
const intermediateState = generateTargetState(currentState, comp);
|
||||
await this.api.setTargetState(intermediateState);
|
||||
|
||||
// Now we wait for the device state to settle
|
||||
await this.awaitDeviceStateSettle();
|
||||
|
||||
// And re-set the target state
|
||||
await this.api.setTargetState(
|
||||
generateTargetState(currentState, this.composition),
|
||||
);
|
||||
|
||||
await this.awaitDeviceStateSettle();
|
||||
|
||||
const instance = this.containers[serviceName];
|
||||
// Get the new container
|
||||
const container = _.find(this.lastDeviceStatus!.containers, {
|
||||
serviceName,
|
||||
});
|
||||
if (container == null) {
|
||||
throw new Error(
|
||||
`Could not find new container for service ${serviceName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buildLogs: Dictionary<string> = {};
|
||||
buildLogs[serviceName] = buildLog;
|
||||
const stageImages = LivepushManager.getMultistageImageIDs(buildLogs);
|
||||
|
||||
instance.livepush = await Livepush.init(
|
||||
buildTask.dockerfile!,
|
||||
buildTask.context!,
|
||||
container.containerId,
|
||||
stageImages[serviceName],
|
||||
this.docker,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.logError(`There was an error rebuilding the service: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static extractDockerArrowMessage(
|
||||
outputLine: string,
|
||||
): string | undefined {
|
||||
const arrowTest = /^.*\s*-+>\s*(.+)/i;
|
||||
const match = arrowTest.exec(outputLine);
|
||||
if (match != null) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
private getDockerfilePathFromTask(task: BuildTask): string[] {
|
||||
switch (task.projectType) {
|
||||
case 'Standard Dockerfile':
|
||||
return ['Dockerfile'];
|
||||
case 'Dockerfile.template':
|
||||
return ['Dockerfile.template'];
|
||||
case 'Architecture-specific Dockerfile':
|
||||
return [
|
||||
`Dockerfile.${this.deviceInfo.arch}`,
|
||||
`Dockerfile.${this.deviceInfo.deviceType}`,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// For each service, get a debounced function
|
||||
private getDebouncedEventHandler = _.memoize((serviceName: string) => {
|
||||
return _.debounce(
|
||||
() => this.handleFSEvents(serviceName),
|
||||
LIVEPUSH_DEBOUNCE_TIMEOUT,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default LivepushManager;
|
120
lib/utils/device/logs.ts
Normal file
120
lib/utils/device/logs.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import * as Bluebird from 'bluebird';
|
||||
import chalk from 'chalk';
|
||||
import ColorHash = require('color-hash');
|
||||
import * as _ from 'lodash';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import Logger = require('../logger');
|
||||
|
||||
interface Log {
|
||||
message: string;
|
||||
timestamp?: number;
|
||||
serviceName?: string;
|
||||
|
||||
// There's also a serviceId and imageId, but they're
|
||||
// meaningless in local mode
|
||||
}
|
||||
|
||||
interface BuildLog {
|
||||
serviceName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display logs from a device logging stream. This function will return
|
||||
* when the log stream ends.
|
||||
*
|
||||
* @param logs A stream which produces newline seperated log
|
||||
* objects
|
||||
* @param logger A Logger instance which the logs will be
|
||||
* displayed through
|
||||
* @param system Only show system (and potentially the
|
||||
* filterService) logs
|
||||
* @param filterService Filter the logs so that only logs
|
||||
* from a single service will be displayed
|
||||
*/
|
||||
export function displayDeviceLogs(
|
||||
logs: Readable,
|
||||
logger: Logger,
|
||||
system: boolean,
|
||||
filterService?: string,
|
||||
): Bluebird<void> {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
logs.on('data', log => {
|
||||
displayLogLine(log, logger, system, filterService);
|
||||
});
|
||||
|
||||
logs.on('error', reject);
|
||||
logs.on('end', () => {
|
||||
logger.logError('Connection to device lost');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function displayBuildLog(log: BuildLog, logger: Logger): void {
|
||||
const toPrint = `${getServiceColourFn(log.serviceName)(
|
||||
`[${log.serviceName}]`,
|
||||
)} ${log.message}`;
|
||||
logger.logBuild(toPrint);
|
||||
}
|
||||
|
||||
// mutates serviceColours
|
||||
function displayLogLine(
|
||||
log: string | Buffer,
|
||||
logger: Logger,
|
||||
system: boolean,
|
||||
filterService?: string,
|
||||
): void {
|
||||
try {
|
||||
const obj: Log = JSON.parse(log.toString());
|
||||
displayLogObject(obj, logger, system, filterService);
|
||||
} catch (e) {
|
||||
logger.logDebug(`Dropping device log due to failed parsing: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function displayLogObject<T extends Log>(
|
||||
obj: T,
|
||||
logger: Logger,
|
||||
system: boolean,
|
||||
filterService?: string,
|
||||
): void {
|
||||
let toPrint: string;
|
||||
if (obj.timestamp != null) {
|
||||
toPrint = `[${new Date(obj.timestamp).toLocaleString()}]`;
|
||||
} else {
|
||||
toPrint = `[${new Date().toLocaleString()}]`;
|
||||
}
|
||||
|
||||
if (obj.serviceName != null) {
|
||||
if (filterService) {
|
||||
if (obj.serviceName !== filterService) {
|
||||
return;
|
||||
}
|
||||
} else if (system) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colourFn = getServiceColourFn(obj.serviceName);
|
||||
|
||||
toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`;
|
||||
} else if (filterService != null && !system) {
|
||||
// We have a system log here but we are filtering based
|
||||
// on a service, so drop this too
|
||||
return;
|
||||
}
|
||||
|
||||
toPrint += ` ${obj.message}`;
|
||||
|
||||
logger.logLogs(toPrint);
|
||||
}
|
||||
|
||||
export const getServiceColourFn = _.memoize(_getServiceColourFn);
|
||||
|
||||
const colorHash = new ColorHash();
|
||||
function _getServiceColourFn(serviceName: string): (msg: string) => string {
|
||||
const [r, g, b] = colorHash.rgb(serviceName);
|
||||
|
||||
return chalk.rgb(r, g, b);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
# Functions to help actions which rely on using docker
|
||||
|
||||
QEMU_VERSION = 'v2.5.50-resin-execve'
|
||||
QEMU_BIN_NAME = 'qemu-execve'
|
||||
Promise = require('bluebird')
|
||||
_ = require('lodash')
|
||||
|
||||
# Use this function to seed an action's list of capitano options
|
||||
# with the docker options. Using this interface means that
|
||||
@ -14,19 +14,19 @@ exports.appendConnectionOptions = appendConnectionOptions = (opts) ->
|
||||
{
|
||||
signature: 'docker'
|
||||
parameter: 'docker'
|
||||
description: 'Path to a local docker socket'
|
||||
description: 'Path to a local docker socket (e.g. /var/run/docker.sock)'
|
||||
alias: 'P'
|
||||
},
|
||||
{
|
||||
signature: 'dockerHost'
|
||||
parameter: 'dockerHost'
|
||||
description: 'The address of the host containing the docker daemon'
|
||||
description: 'Docker daemon hostname or IP address (dev machine or balena device) '
|
||||
alias: 'h'
|
||||
},
|
||||
{
|
||||
signature: 'dockerPort'
|
||||
parameter: 'dockerPort'
|
||||
description: 'The port on which the host docker daemon is listening'
|
||||
description: 'Docker daemon TCP port number (hint: 2375 for balena devices)'
|
||||
alias: 'p'
|
||||
},
|
||||
{
|
||||
@ -71,12 +71,6 @@ exports.appendOptions = (opts) ->
|
||||
description: "Don't use docker layer caching when building"
|
||||
boolean: true
|
||||
},
|
||||
{
|
||||
signature: 'emulated'
|
||||
description: 'Run an emulated build using Qemu'
|
||||
boolean: true
|
||||
alias: 'e'
|
||||
},
|
||||
{
|
||||
signature: 'squash'
|
||||
description: 'Squash newly built layers into a single new layer'
|
||||
@ -84,8 +78,7 @@ exports.appendOptions = (opts) ->
|
||||
}
|
||||
]
|
||||
|
||||
exports.generateConnectOpts = generateConnectOpts = (opts) ->
|
||||
Promise = require('bluebird')
|
||||
generateConnectOpts = (opts) ->
|
||||
buildDockerodeOpts = require('dockerode-options')
|
||||
fs = require('mz/fs')
|
||||
_ = require('lodash')
|
||||
@ -131,290 +124,61 @@ exports.generateConnectOpts = generateConnectOpts = (opts) ->
|
||||
|
||||
return connectOpts
|
||||
|
||||
exports.tarDirectory = tarDirectory = (dir) ->
|
||||
Promise = require('bluebird')
|
||||
tar = require('tar-stream')
|
||||
klaw = require('klaw')
|
||||
path = require('path')
|
||||
fs = require('mz/fs')
|
||||
streamToPromise = require('stream-to-promise')
|
||||
|
||||
getFiles = ->
|
||||
streamToPromise(klaw(dir))
|
||||
.filter((item) -> not item.stats.isDirectory())
|
||||
.map((item) -> item.path)
|
||||
|
||||
pack = tar.pack()
|
||||
getFiles(dir)
|
||||
.map (file) ->
|
||||
relPath = path.relative(path.resolve(dir), file)
|
||||
Promise.join relPath, fs.stat(file), fs.readFile(file),
|
||||
(filename, stats, data) ->
|
||||
pack.entryAsync({ name: filename, size: stats.size, mode: stats.mode }, data)
|
||||
.then ->
|
||||
pack.finalize()
|
||||
return pack
|
||||
|
||||
cacheHighlightStream = ->
|
||||
colors = require('colors/safe')
|
||||
es = require('event-stream')
|
||||
{ EOL } = require('os')
|
||||
|
||||
extractArrowMessage = (message) ->
|
||||
arrowTest = /^\s*-+>\s*(.+)/i
|
||||
if (match = arrowTest.exec(message))
|
||||
match[1]
|
||||
else
|
||||
undefined
|
||||
|
||||
es.mapSync (data) ->
|
||||
msg = extractArrowMessage(data)
|
||||
if msg? and msg.toLowerCase() == 'using cache'
|
||||
data = colors.bgGreen.black(msg)
|
||||
return data + EOL
|
||||
|
||||
parseBuildArgs = (args, onError) ->
|
||||
parseBuildArgs = (args) ->
|
||||
_ = require('lodash')
|
||||
if not _.isArray(args)
|
||||
args = [ args ]
|
||||
buildArgs = {}
|
||||
args.forEach (str) ->
|
||||
pair = /^([^\s]+?)=(.*)$/.exec(str)
|
||||
args.forEach (arg) ->
|
||||
pair = /^([^\s]+?)=(.*)$/.exec(arg)
|
||||
if pair?
|
||||
buildArgs[pair[1]] = pair[2]
|
||||
buildArgs[pair[1]] = pair[2] ? ''
|
||||
else
|
||||
onError(str)
|
||||
throw new Error("Could not parse build argument: '#{arg}'")
|
||||
return buildArgs
|
||||
|
||||
# Pass in the command line parameters and options and also
|
||||
# a function which will return the information about the bundle
|
||||
exports.runBuild = (params, options, getBundleInfo, logger) ->
|
||||
Promise = require('bluebird')
|
||||
dockerBuild = require('resin-docker-build')
|
||||
resolver = require('resin-bundle-resolve')
|
||||
es = require('event-stream')
|
||||
doodles = require('resin-doodles')
|
||||
transpose = require('docker-qemu-transpose')
|
||||
path = require('path')
|
||||
|
||||
# The default build context is the current directory
|
||||
params.source ?= '.'
|
||||
logs = ''
|
||||
# Only used in emulated builds
|
||||
qemuPath = ''
|
||||
|
||||
Promise.try ->
|
||||
return if not (options.emulated and platformNeedsQemu())
|
||||
|
||||
hasQemu()
|
||||
.then (present) ->
|
||||
if !present
|
||||
logger.logInfo('Installing qemu for ARM emulation...')
|
||||
installQemu()
|
||||
.then ->
|
||||
# Copy the qemu binary into the build context
|
||||
copyQemu(params.source)
|
||||
.then (binPath) ->
|
||||
qemuPath = path.relative(params.source, binPath)
|
||||
.then ->
|
||||
# Tar up the directory, ready for the build stream
|
||||
tarDirectory(params.source)
|
||||
.then (tarStream) ->
|
||||
new Promise (resolve, reject) ->
|
||||
hooks =
|
||||
buildSuccess: (image) ->
|
||||
# Show charlie. In the interest of cloud parity,
|
||||
# use console.log, not the standard logging streams
|
||||
doodle = doodles.getDoodle()
|
||||
console.log()
|
||||
console.log(doodle)
|
||||
console.log()
|
||||
|
||||
resolve({ image, log: logs + '\n' + doodle + '\n' } )
|
||||
|
||||
buildFailure: reject
|
||||
buildStream: (stream) ->
|
||||
if options.emulated
|
||||
logger.logInfo('Running emulated build')
|
||||
|
||||
getBundleInfo(options)
|
||||
.then (info) ->
|
||||
if !info?
|
||||
logger.logWarn '''
|
||||
Warning: No architecture/device type or application information provided.
|
||||
Dockerfile/project pre-processing will not be performed.
|
||||
'''
|
||||
return tarStream
|
||||
else
|
||||
[arch, deviceType] = info
|
||||
# Perform type resolution on the project
|
||||
bundle = new resolver.Bundle(tarStream, deviceType, arch)
|
||||
resolver.resolveBundle(bundle, resolver.getDefaultResolvers())
|
||||
.then (resolved) ->
|
||||
logger.logInfo("Building #{resolved.projectType} project")
|
||||
|
||||
return resolved.tarStream
|
||||
.then (buildStream) ->
|
||||
# if we need emulation
|
||||
if options.emulated and platformNeedsQemu()
|
||||
return transpose.transposeTarStream buildStream,
|
||||
hostQemuPath: qemuPath
|
||||
containerQemuPath: "/tmp/#{QEMU_BIN_NAME}"
|
||||
else
|
||||
return buildStream
|
||||
.then (buildStream) ->
|
||||
# Send the resolved tar stream to the docker daemon
|
||||
buildStream.pipe(stream)
|
||||
.catch(reject)
|
||||
|
||||
# And print the output
|
||||
logThroughStream = es.through (data) ->
|
||||
logs += data.toString()
|
||||
this.emit('data', data)
|
||||
|
||||
if options.emulated and platformNeedsQemu()
|
||||
buildThroughStream = transpose.getBuildThroughStream
|
||||
hostQemuPath: qemuPath
|
||||
containerQemuPath: "/tmp/#{QEMU_BIN_NAME}"
|
||||
|
||||
newStream = stream.pipe(buildThroughStream)
|
||||
else
|
||||
newStream = stream
|
||||
|
||||
newStream
|
||||
.pipe(logThroughStream)
|
||||
.pipe(cacheHighlightStream())
|
||||
.pipe(logger.streams.build)
|
||||
|
||||
# Create a builder
|
||||
generateConnectOpts(options)
|
||||
.tap (connectOpts) ->
|
||||
ensureDockerSeemsAccessible(connectOpts)
|
||||
.then (connectOpts) ->
|
||||
# Allow degugging output, hidden behind an env var
|
||||
logger.logDebug('Connecting with the following options:')
|
||||
logger.logDebug(JSON.stringify(connectOpts, null, ' '))
|
||||
|
||||
builder = new dockerBuild.Builder(connectOpts)
|
||||
opts = {}
|
||||
|
||||
if options.tag?
|
||||
opts['t'] = options.tag
|
||||
if options.nocache?
|
||||
opts['nocache'] = true
|
||||
if options.buildArg?
|
||||
opts['buildargs'] = parseBuildArgs options.buildArg, (arg) ->
|
||||
logger.logWarn("Could not parse variable: '#{arg}'")
|
||||
if options.squash?
|
||||
opts['squash'] = true
|
||||
|
||||
builder.createBuildStream(opts, hooks, reject)
|
||||
|
||||
# Given an image id or tag, export the image to a tar archive,
|
||||
# gzip the result, and buffer it to disk.
|
||||
exports.bufferImage = (docker, imageId, bufferFile) ->
|
||||
Promise = require('bluebird')
|
||||
streamUtils = require('./streams')
|
||||
|
||||
image = docker.getImage(imageId)
|
||||
imageMetadata = image.inspectAsync()
|
||||
|
||||
Promise.join image.get(), imageMetadata.get('Size'), (imageStream, imageSize) ->
|
||||
streamUtils.buffer(imageStream, bufferFile)
|
||||
.tap (bufferedStream) ->
|
||||
bufferedStream.length = imageSize
|
||||
exports.generateBuildOpts = (options) ->
|
||||
opts = {}
|
||||
if options.tag?
|
||||
opts.t = options.tag
|
||||
if options.nocache?
|
||||
opts.nocache = true
|
||||
if options.squash?
|
||||
opts.squash = true
|
||||
if options.buildArg?
|
||||
opts.buildargs = parseBuildArgs(options.buildArg)
|
||||
if not _.isEmpty(options['registry-secrets'])
|
||||
opts.registryconfig = options['registry-secrets']
|
||||
return opts
|
||||
|
||||
exports.getDocker = (options) ->
|
||||
Docker = require('dockerode')
|
||||
Promise = require('bluebird')
|
||||
|
||||
generateConnectOpts(options)
|
||||
.tap (connectOpts) ->
|
||||
ensureDockerSeemsAccessible(connectOpts)
|
||||
.then (connectOpts) ->
|
||||
# Use bluebird's promises
|
||||
connectOpts['Promise'] = Promise
|
||||
new Docker(connectOpts)
|
||||
.then(createClient)
|
||||
.tap(ensureDockerSeemsAccessible)
|
||||
|
||||
ensureDockerSeemsAccessible = (options) ->
|
||||
fs = require('mz/fs')
|
||||
getDockerToolbelt = _.once ->
|
||||
Docker = require('docker-toolbelt')
|
||||
Promise.promisifyAll Docker.prototype, {
|
||||
filter: (name) -> name == 'run'
|
||||
multiArgs: true
|
||||
}
|
||||
Promise.promisifyAll(Docker.prototype)
|
||||
Promise.promisifyAll(new Docker({}).getImage().constructor.prototype)
|
||||
Promise.promisifyAll(new Docker({}).getContainer().constructor.prototype)
|
||||
return Docker
|
||||
|
||||
if options.socketPath?
|
||||
# If we're trying to use a socket, check it exists and we have access to it
|
||||
fs.access(options.socketPath, (fs.constants || fs).R_OK | (fs.constants || fs).W_OK)
|
||||
.return(true)
|
||||
.catch (err) ->
|
||||
throw new Error(
|
||||
"Docker seems to be unavailable (using socket #{options.socketPath}). Is it
|
||||
installed, and do you have permission to talk to it?"
|
||||
)
|
||||
else
|
||||
# Otherwise, we think we're probably ok
|
||||
Promise.resolve(true)
|
||||
# docker-toolbelt v3 is not backwards compatible as it removes all *Async
|
||||
# methods that are in wide use in the CLI. The workaround for now is to
|
||||
# manually promisify the client and replace all `new Docker()` calls with
|
||||
# this shared function that returns a promisified client.
|
||||
#
|
||||
# **New code must not use the *Async methods.**
|
||||
#
|
||||
exports.createClient = createClient = (opts) ->
|
||||
Docker = getDockerToolbelt()
|
||||
return new Docker(opts)
|
||||
|
||||
hasQemu = ->
|
||||
fs = require('mz/fs')
|
||||
|
||||
getQemuPath()
|
||||
.then(fs.stat)
|
||||
.return(true)
|
||||
.catchReturn(false)
|
||||
|
||||
getQemuPath = ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
path = require('path')
|
||||
fs = require('mz/fs')
|
||||
|
||||
resin.settings.get('binDirectory')
|
||||
.then (binDir) ->
|
||||
# The directory might not be created already,
|
||||
# if not, create it
|
||||
fs.access(binDir)
|
||||
.catch code: 'ENOENT', ->
|
||||
fs.mkdir(binDir)
|
||||
.then ->
|
||||
path.join(binDir, QEMU_BIN_NAME)
|
||||
|
||||
platformNeedsQemu = ->
|
||||
os = require('os')
|
||||
os.platform() == 'linux'
|
||||
|
||||
installQemu = ->
|
||||
request = require('request')
|
||||
fs = require('fs')
|
||||
zlib = require('zlib')
|
||||
|
||||
getQemuPath()
|
||||
.then (qemuPath) ->
|
||||
new Promise (resolve, reject) ->
|
||||
installStream = fs.createWriteStream(qemuPath)
|
||||
qemuUrl = "https://github.com/resin-io/qemu/releases/download/#{QEMU_VERSION}/#{QEMU_BIN_NAME}.gz"
|
||||
request(qemuUrl)
|
||||
.pipe(zlib.createGunzip())
|
||||
.pipe(installStream)
|
||||
.on('error', reject)
|
||||
.on('finish', resolve)
|
||||
|
||||
copyQemu = (context) ->
|
||||
path = require('path')
|
||||
fs = require('mz/fs')
|
||||
# Create a hidden directory in the build context, containing qemu
|
||||
binDir = path.join(context, '.resin')
|
||||
binPath = path.join(binDir, QEMU_BIN_NAME)
|
||||
|
||||
fs.access(binDir)
|
||||
.catch code: 'ENOENT', ->
|
||||
fs.mkdir(binDir)
|
||||
.then ->
|
||||
getQemuPath()
|
||||
.then (qemu) ->
|
||||
new Promise (resolve, reject) ->
|
||||
read = fs.createReadStream(qemu)
|
||||
write = fs.createWriteStream(binPath)
|
||||
read
|
||||
.pipe(write)
|
||||
.on('error', reject)
|
||||
.on('finish', resolve)
|
||||
.then ->
|
||||
fs.chmod(binPath, '755')
|
||||
.return(binPath)
|
||||
ensureDockerSeemsAccessible = (docker) ->
|
||||
{ exitWithExpectedError } = require('./patterns')
|
||||
docker.ping().catch ->
|
||||
exitWithExpectedError('Docker seems to be unavailable. Is it installed and running?')
|
||||
|
@ -1,135 +0,0 @@
|
||||
###
|
||||
Copyright 2016-2017 Resin.io
|
||||
|
||||
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.
|
||||
###
|
||||
|
||||
Promise = require('bluebird')
|
||||
|
||||
exports.getGroupDefaults = (group) ->
|
||||
_ = require('lodash')
|
||||
|
||||
return _.chain(group)
|
||||
.get('options')
|
||||
.map (question) ->
|
||||
return [ question.name, question.default ]
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
exports.stateToString = (state) ->
|
||||
_str = require('underscore.string')
|
||||
chalk = require('chalk')
|
||||
|
||||
percentage = _str.lpad(state.percentage, 3, '0') + '%'
|
||||
result = "#{chalk.blue(percentage)} #{chalk.cyan(state.operation.command)}"
|
||||
|
||||
switch state.operation.command
|
||||
when 'copy'
|
||||
return "#{result} #{state.operation.from.path} -> #{state.operation.to.path}"
|
||||
when 'replace'
|
||||
return "#{result} #{state.operation.file.path}, #{state.operation.copy} -> #{state.operation.replace}"
|
||||
when 'run-script'
|
||||
return "#{result} #{state.operation.script}"
|
||||
else
|
||||
throw new Error("Unsupported operation: #{state.operation.type}")
|
||||
|
||||
exports.sudo = (command) ->
|
||||
_ = require('lodash')
|
||||
os = require('os')
|
||||
|
||||
if os.platform() isnt 'win32'
|
||||
console.log('If asked please type your computer password to continue')
|
||||
|
||||
command = _.union(_.take(process.argv, 2), command)
|
||||
presidentExecuteAsync = Promise.promisify(require('president').execute)
|
||||
return presidentExecuteAsync(command)
|
||||
|
||||
exports.getManifest = (image, deviceType) ->
|
||||
rindle = require('rindle')
|
||||
imagefs = require('resin-image-fs')
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
# Attempt to read manifest from the first
|
||||
# partition, but fallback to the API if
|
||||
# we encounter any errors along the way.
|
||||
imagefs.read
|
||||
image: image
|
||||
partition:
|
||||
primary: 1
|
||||
path: '/device-type.json'
|
||||
.then(rindle.extractAsync)
|
||||
.then(JSON.parse)
|
||||
.catch ->
|
||||
resin.models.device.getManifestBySlug(deviceType)
|
||||
|
||||
exports.osProgressHandler = (step) ->
|
||||
rindle = require('rindle')
|
||||
visuals = require('resin-cli-visuals')
|
||||
|
||||
step.on('stdout', process.stdout.write.bind(process.stdout))
|
||||
step.on('stderr', process.stderr.write.bind(process.stderr))
|
||||
|
||||
step.on 'state', (state) ->
|
||||
return if state.operation.command is 'burn'
|
||||
console.log(exports.stateToString(state))
|
||||
|
||||
progressBars =
|
||||
write: new visuals.Progress('Writing Device OS')
|
||||
check: new visuals.Progress('Validating Device OS')
|
||||
|
||||
step.on 'burn', (state) ->
|
||||
progressBars[state.type].update(state)
|
||||
|
||||
return rindle.wait(step)
|
||||
|
||||
exports.getAppInfo = (application) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
_ = require('lodash')
|
||||
Promise.join(
|
||||
getApplication(application),
|
||||
resin.models.config.getDeviceTypes(),
|
||||
(app, config) ->
|
||||
config = _.find(config, 'slug': app.device_type)
|
||||
if !config?
|
||||
throw new Error('Could not read application information!')
|
||||
app.arch = config.arch
|
||||
return app
|
||||
)
|
||||
|
||||
getApplication = (application) ->
|
||||
resin = require('resin-sdk-preconfigured')
|
||||
|
||||
# Check for an app of the form `user/application`, and send
|
||||
# this off to a special handler (before importing any modules)
|
||||
if (match = /(\w+)\/(\w+)/.exec(application))
|
||||
return resin.models.application.getAppWithOwner(match[2], match[1])
|
||||
|
||||
return resin.models.application.get(application)
|
||||
|
||||
# A function to reliably execute a command
|
||||
# in all supported operating systems, including
|
||||
# different Windows environments like `cmd.exe`
|
||||
# and `Cygwin`.
|
||||
exports.getSubShellCommand = (command) ->
|
||||
os = require('os')
|
||||
|
||||
if os.platform() is 'win32'
|
||||
return {
|
||||
program: 'cmd.exe'
|
||||
args: [ '/s', '/c', command ]
|
||||
}
|
||||
else
|
||||
return {
|
||||
program: '/bin/sh'
|
||||
args: [ '-c', command ]
|
||||
}
|
225
lib/utils/helpers.ts
Normal file
225
lib/utils/helpers.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/*
|
||||
Copyright 2016-2019 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 BalenaSdk = require('balena-sdk');
|
||||
import Bluebird = require('bluebird');
|
||||
import chalk from 'chalk';
|
||||
import _ = require('lodash');
|
||||
import os = require('os');
|
||||
import visuals = require('resin-cli-visuals');
|
||||
import rindle = require('rindle');
|
||||
|
||||
import { InitializeEmitter, OperationState } from 'balena-device-init';
|
||||
|
||||
const waitStreamAsync = Bluebird.promisify(rindle.wait);
|
||||
const balena = BalenaSdk.fromSharedOptions();
|
||||
|
||||
export function getGroupDefaults(group: {
|
||||
options: Array<{ name: string; default?: string }>;
|
||||
}): { [name: string]: string | undefined } {
|
||||
return _.chain(group)
|
||||
.get('options')
|
||||
.map(question => [question.name, question.default])
|
||||
.fromPairs()
|
||||
.value();
|
||||
}
|
||||
|
||||
export function stateToString(state: OperationState) {
|
||||
const percentage = _.padStart(`${state.percentage}`, 3, '0');
|
||||
const result = `${chalk.blue(percentage + '%')} ${chalk.cyan(
|
||||
state.operation.command,
|
||||
)}`;
|
||||
|
||||
switch (state.operation.command) {
|
||||
case 'copy':
|
||||
return `${result} ${state.operation.from.path} -> ${
|
||||
state.operation.to.path
|
||||
}`;
|
||||
case 'replace':
|
||||
return `${result} ${state.operation.file.path}, ${
|
||||
state.operation.copy
|
||||
} -> ${state.operation.replace}`;
|
||||
case 'run-script':
|
||||
return `${result} ${state.operation.script}`;
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${state.operation.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function sudo(
|
||||
command: string[],
|
||||
{ stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string } = {},
|
||||
) {
|
||||
const { executeWithPrivileges } = require('./sudo');
|
||||
|
||||
if (os.platform() !== 'win32') {
|
||||
console.log(
|
||||
msg || 'If asked please type your computer password to continue',
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithPrivileges(command, stderr);
|
||||
}
|
||||
|
||||
export function runCommand(command: string): Bluebird<void> {
|
||||
const capitano = require('capitano');
|
||||
return Bluebird.fromCallback(resolver => capitano.run(command, resolver));
|
||||
}
|
||||
|
||||
export async function getManifest(
|
||||
image: string,
|
||||
deviceType: string,
|
||||
): Promise<BalenaSdk.DeviceType> {
|
||||
const init = await import('balena-device-init');
|
||||
const manifest = await init.getImageManifest(image);
|
||||
if (manifest != null) {
|
||||
return manifest;
|
||||
}
|
||||
return balena.models.device.getManifestBySlug(deviceType);
|
||||
}
|
||||
|
||||
export const areDeviceTypesCompatible = (
|
||||
deviceTypeA: BalenaSdk.DeviceType,
|
||||
deviceTypeB: BalenaSdk.DeviceType,
|
||||
) =>
|
||||
deviceTypeA.arch === deviceTypeB.arch &&
|
||||
!!deviceTypeA.isDependent === !!deviceTypeB.isDependent;
|
||||
|
||||
export async function getOsVersion(
|
||||
image: string,
|
||||
manifest: BalenaSdk.DeviceType,
|
||||
): Promise<string | null> {
|
||||
const init = await import('balena-device-init');
|
||||
return init.getImageOsVersion(image, manifest);
|
||||
}
|
||||
|
||||
export function osProgressHandler(step: InitializeEmitter) {
|
||||
step.on('stdout', process.stdout.write.bind(process.stdout));
|
||||
step.on('stderr', process.stderr.write.bind(process.stderr));
|
||||
|
||||
step.on('state', function(state) {
|
||||
if (state.operation.command === 'burn') {
|
||||
return;
|
||||
}
|
||||
console.log(exports.stateToString(state));
|
||||
});
|
||||
|
||||
const progressBars = {
|
||||
write: new visuals.Progress('Writing Device OS'),
|
||||
check: new visuals.Progress('Validating Device OS'),
|
||||
};
|
||||
|
||||
step.on('burn', state => progressBars[state.type].update(state));
|
||||
|
||||
return waitStreamAsync(step);
|
||||
}
|
||||
|
||||
export function getArchAndDeviceType(
|
||||
applicationName: string,
|
||||
): Bluebird<{ arch: string; device_type: string }> {
|
||||
return Bluebird.join(
|
||||
getApplication(applicationName),
|
||||
balena.models.config.getDeviceTypes(),
|
||||
function(app, deviceTypes) {
|
||||
const config = _.find(deviceTypes, { slug: app.device_type });
|
||||
|
||||
if (!config) {
|
||||
throw new Error('Could not read application information!');
|
||||
}
|
||||
|
||||
return { device_type: app.device_type, arch: config.arch };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getApplication(applicationName: string) {
|
||||
// Check for an app of the form `user/application`, and send
|
||||
// that off to a special handler (before importing any modules)
|
||||
const match = applicationName.split('/');
|
||||
|
||||
const extraOptions = {
|
||||
$expand: {
|
||||
application_type: {
|
||||
$select: ['name', 'slug', 'supports_multicontainer', 'is_legacy'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (match.length > 1) {
|
||||
return balena.models.application.getAppByOwner(
|
||||
match[1],
|
||||
match[0],
|
||||
extraOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return balena.models.application.get(applicationName, extraOptions);
|
||||
}
|
||||
|
||||
// A function to reliably execute a command
|
||||
// in all supported operating systems, including
|
||||
// different Windows environments like `cmd.exe`
|
||||
// and `Cygwin`.
|
||||
export function getSubShellCommand(command: string) {
|
||||
if (os.platform() === 'win32') {
|
||||
return {
|
||||
program: 'cmd.exe',
|
||||
args: ['/s', '/c', command],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
program: '/bin/sh',
|
||||
args: ['-c', command],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `func`, and if func() throws an error or returns a promise that
|
||||
* eventually rejects, retry it `times` many times, each time printing a
|
||||
* log message including the given `label` and the error that led to
|
||||
* retrying. Wait delayMs before the first retry, multiplying the wait
|
||||
* by backoffScaler for each further attempt.
|
||||
* @param func: The function to call and, if needed, retry calling
|
||||
* @param times: How many times to retry calling func()
|
||||
* @param label: Label to include in the retry log message
|
||||
* @param delayMs: How long to wait before the first retry
|
||||
* @param backoffScaler: Multiplier to previous wait time
|
||||
* @param count: Used "internally" for the recursive calls
|
||||
*/
|
||||
export function retry<T>(
|
||||
func: () => T,
|
||||
times: number,
|
||||
label: string,
|
||||
delayMs = 1000,
|
||||
backoffScaler = 2,
|
||||
count = 0,
|
||||
): Bluebird<T> {
|
||||
let promise = Bluebird.try(func);
|
||||
if (count < times) {
|
||||
promise = promise.catch((err: Error) => {
|
||||
const delay = backoffScaler ** count * delayMs;
|
||||
console.log(
|
||||
`Retrying "${label}" after ${(delay / 1000).toFixed(2)}s (${count +
|
||||
1} of ${times}) due to: ${err}`,
|
||||
);
|
||||
return Bluebird.delay(delay).then(() =>
|
||||
retry(func, times, label, delayMs, backoffScaler, count + 1),
|
||||
);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user