forked from toolshed/abra
Compare commits
1038 Commits
0.4.0-alph
...
abra-app-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 126acee958 | |||
|
5cf6048ecb
|
|||
|
3e2797c433
|
|||
| df89e8143a | |||
| b4ddd3e77c | |||
|
81c28e3006
|
|||
|
34d2e3b092
|
|||
|
1894c2f5fc
|
|||
|
e0bd03bec3
|
|||
|
77ff146991
|
|||
|
6fad1a1dcc
|
|||
|
a90e239547
|
|||
|
9ee094fcd7
|
|||
|
1aa7016789
|
|||
| 60b3af1fa4 | |||
| 5f4b5e0fad | |||
| feadfca0d6 | |||
| 73d4ee1c98 | |||
|
f46c18c8d7
|
|||
| f5a843bd90 | |||
|
fac372dc73
|
|||
|
8a3be01c3e
|
|||
|
4193d63d23
|
|||
|
38f308910a
|
|||
|
4aaa7400b8
|
|||
| 091611b984 | |||
| 2cfc40dc28 | |||
| 6849e3554d | |||
|
452de7fdc2
|
|||
|
952d768ab0
|
|||
| 2c91d2040e | |||
| eff4435971 | |||
| 032fe99086 | |||
| 7add56df00 | |||
| 0ab05cece2 | |||
| c63f6db61e | |||
| 56a68dfa91 | |||
| 157d131b37 | |||
| 3fae036db2 | |||
| ce9d0934b6 | |||
|
a32e30374f
|
|||
|
cf46569f04
|
|||
|
022606c13c
|
|||
|
8cfda5229f
|
|||
|
855a4c37c4
|
|||
| 7c3b740e14 | |||
|
2fbef41a3a
|
|||
| 6fb41e5300 | |||
| 1432f480c7 | |||
|
83af39771b
|
|||
|
4d1333202e
|
|||
|
55c24f070c
|
|||
| 229e8eb9da | |||
|
b3ab95750e
|
|||
| de009921a2 | |||
|
d081bbaefa
|
|||
|
515b5466ca
|
|||
|
6965799bdc
|
|||
|
f75c9a6259
|
|||
| a43a092ba7 | |||
| fa084a61d2 | |||
|
895a7fe7d6
|
|||
|
742a726778
|
|||
|
2b9a185aff
|
|||
|
b7c1e87c0b
|
|||
|
cdfb8a08bb
|
|||
|
8943cea13f
|
|||
|
6d64e0edd3
|
|||
| 47045ca8f1 | |||
| d0f982456e | |||
| 80ad6c6681 | |||
|
cb63cfe9c2
|
|||
|
d1e49d17ce
|
|||
|
1574aa0631
|
|||
|
1723025fbf
|
|||
|
a2b678caf6
|
|||
| 0a371ec360 | |||
| e58a716fe1 | |||
| d09a19a385 | |||
| cee808ff06 | |||
| 4326d1d259 | |||
| b976872f77 | |||
| 7b6ea76437 | |||
| 9069758969 | |||
| 15d6b1a2a5 | |||
|
8a7fe4ca07
|
|||
| 64ad60663f | |||
| cb3f46b46e | |||
|
41e514ae9a
|
|||
|
086b4828ff
|
|||
|
ed263854d4
|
|||
|
eb6fe4ba6e
|
|||
| 993172d31b | |||
| c70b6e72a7 | |||
| 22e4dd7fca | |||
|
b6009057a8
|
|||
|
b978f04910
|
|||
|
3ac29d54d9
|
|||
|
877c17fab5
|
|||
|
f01fd26ce3
|
|||
|
273c165a41
|
|||
|
c88fc66c99
|
|||
|
9b271a6963
|
|||
|
8af87aa382
|
|||
|
ac0b9cd052
|
|||
|
4923984e84
|
|||
|
2bc77de751
|
|||
|
b3a2402cec
|
|||
|
a773fd4256
|
|||
|
b1a0d54bd3
|
|||
|
3869d6bce9
|
|||
|
0ff07ab224
|
|||
|
936c1b0626
|
|||
|
b576cba227
|
|||
|
d087f3debf
|
|||
|
e57a6d87a3
|
|||
|
74b64099de
|
|||
|
354712ca46
|
|||
|
81cdc843ec
|
|||
|
d2931e3af0
|
|||
|
b9f2d1f568
|
|||
| a379b31a19 | |||
|
17e15dba77
|
|||
|
1194f3b228
|
|||
|
2dc8034c16
|
|||
|
c5ddeb2d8a
|
|||
|
0a63f9ce27
|
|||
|
3a71dc47f8
|
|||
|
f07c64f7b8
|
|||
|
dd03c40e10
|
|||
|
48198d55bd
|
|||
|
c0931b96d8
|
|||
|
64ea0f9684
|
|||
|
b0cd8ccbb9
|
|||
|
5975be6870
|
|||
|
bfed51a69c
|
|||
|
5d0faf5e13
|
|||
|
cd6af9708c
|
|||
|
ef95bce1e4
|
|||
|
a159583874
|
|||
|
e3b0500875
|
|||
|
994310a4ff
|
|||
| 74108b0dd9 | |||
|
3727c7fa78
|
|||
|
9a4414fd13
|
|||
|
9f189680f3
|
|||
|
356e527f1f
|
|||
|
7ec61c6d03
|
|||
|
fab93a559a
|
|||
|
8ac31330be
|
|||
|
03000c25e0
|
|||
|
3f32dbb1a3
|
|||
|
27f68b1dc5
|
|||
| a0da5299fe | |||
|
866c5c4536
|
|||
|
dc4c6784cb
|
|||
| 97959ef5da | |||
| b6573720ec | |||
|
4e8995cc0e
|
|||
|
efb3fd8759
|
|||
|
008582c3d9
|
|||
|
8fa20e2c7f
|
|||
|
aa1dc795ef
|
|||
|
18df498295
|
|||
|
671e1ca276
|
|||
|
0df2b15c33
|
|||
|
3f29084664
|
|||
|
0bb25a00ec
|
|||
| 28c7676413 | |||
| 730fef09a3 | |||
| 8d076a308a | |||
| 9510c04aeb | |||
|
d9e60afd71
|
|||
|
31fa9b1a7a
|
|||
| f664599836 | |||
| bba1640913 | |||
| 7b54c2b5b9 | |||
| 8ee1947fe9 | |||
|
b313b0a145
|
|||
|
1f9b863be0
|
|||
|
3b3ce85ef9
|
|||
|
1f8662cd95
|
|||
|
375e17a4a0
|
|||
|
04aec8232f
|
|||
|
2a5985e44e
|
|||
|
c65be64e7d
|
|||
|
fd8652e26d
|
|||
|
518c5795f4
|
|||
|
827edcb0da
|
|||
|
05489a129c
|
|||
|
c02e11eb0a
|
|||
|
8b8e158664
|
|||
|
e5a6dea10c
|
|||
|
1132b09b5b
|
|||
|
b2436174b0
|
|||
|
ea10019068
|
|||
|
9b0b3c2e4c
|
|||
|
8084bff104
|
|||
|
d22e2c38ce
|
|||
|
e945087f79
|
|||
|
7734dd555d
|
|||
|
aedf5e5ff7
|
|||
|
95c598d030
|
|||
|
56068362e8
|
|||
|
cf14731b46
|
|||
|
486cfa68d8
|
|||
|
1718903834
|
|||
|
eb9894e5bb
|
|||
|
a2116774e8
|
|||
|
d2efdf8bf5
|
|||
|
b15c05929c
|
|||
|
f167a91868
|
|||
|
8cded8752a
|
|||
|
d1876e2fae
|
|||
|
e42a1bca29
|
|||
|
b5493ba059
|
|||
|
a41a36b8fd
|
|||
|
de006782b6
|
|||
|
f28cffe6d8
|
|||
|
d3ede0f0f6
|
|||
|
ae4653f5e3
|
|||
| 7f0a74d3c3 | |||
| e99114e695 | |||
| b1208f9db5 | |||
|
b8e1a3b75f
|
|||
|
ff90b43929
|
|||
| c5724d56f8 | |||
|
ce7dda1eae
|
|||
|
d38f3ab7f5
|
|||
|
4be8c8daed
|
|||
|
3c9405a4ed
|
|||
| f6b7510da6 | |||
| 7596982282 | |||
| 4085eb6654 | |||
| 790dbca362 | |||
| d7a870b887 | |||
|
1a3ec7a107
|
|||
|
7f910b4e5b
|
|||
|
b82ac3bd63
|
|||
|
00d60f7114
|
|||
|
71d93cbbea
|
|||
|
2fb5493ab5
|
|||
|
0ff8e49cfd
|
|||
|
addbda9145
|
|||
|
c33ca1c6bc
|
|||
|
4580df72cb
|
|||
|
f003430a8d
|
|||
|
5426464092
|
|||
|
72c021c727
|
|||
|
f2e076b35f
|
|||
|
4ccb4198d6
|
|||
|
a9f7579ca9
|
|||
| 9cd1fe658b | |||
| 41c16db670 | |||
| 87ecc05962 | |||
| f14d49cc64 | |||
| f638b6a16b | |||
| 5617a9ba07 | |||
| c1b03bcbd7 | |||
| 99da8d4e57 | |||
| ca1db33e97 | |||
| eb62e0ecc3 | |||
| 6f90fc3025 | |||
| c861c09cce | |||
| 2f41b6d8b4 | |||
| 73e9b818b4 | |||
| f268e5893b | |||
| 47013c63d6 | |||
| 4cf6155fb8 | |||
| 01f3f4be17 | |||
| eee2ecda06 | |||
| 950f85e2b4 | |||
|
9ef64778f5
|
|||
|
735f521bc0
|
|||
|
96a25425a4
|
|||
|
1a8dca9804
|
|||
|
465827d5ee
|
|||
|
cde06f4f00
|
|||
|
050a479df7
|
|||
|
ef108d63e1
|
|||
|
cf8ff410cc
|
|||
|
6ec678208f
|
|||
|
a001be3021
|
|||
|
6712bd446f
|
|||
|
1097daa69f
|
|||
|
beaa233421
|
|||
|
f871f9beee
|
|||
|
0f8f0f908f
|
|||
|
c5211fbd7e
|
|||
| 0076b31253 | |||
| 37aff723c0 | |||
| f18c642226 | |||
| ac695ae28e | |||
|
ac87898005
|
|||
|
32ae2499b6
|
|||
|
1136ec5dcd
|
|||
|
6a2db1abaa
|
|||
|
9554ad40c8
|
|||
|
2014cd6622
|
|||
|
a9ce2106c6
|
|||
|
34de38928a
|
|||
|
f58522d822
|
|||
|
712ebfb701
|
|||
|
1fe601cd16
|
|||
|
7b7e1bfa97
|
|||
|
1a12bef53e
|
|||
|
d787f71215
|
|||
|
9bf44c15ed
|
|||
|
349cacc1f2
|
|||
| 938534f5ac | |||
| 6cd331ebd6 | |||
|
40517171f7
|
|||
| b2485cc122 | |||
| 9ec99c7712 | |||
|
aa3910f8df
|
|||
|
43990b6fae
|
|||
|
91ea2c01a5
|
|||
|
316fdd3643
|
|||
|
e07ae8cccd
|
|||
|
300a4ead01
|
|||
|
f209b6f564
|
|||
|
791183adfe
|
|||
| e6b35e8524 | |||
| 8a0274cac0 | |||
| e609924af0 | |||
| 70e2943301 | |||
| 0590c1824d | |||
| 459abecfa5 | |||
| 183ad8f576 | |||
|
03f94da2d8
|
|||
| 766f69b0fd | |||
|
004cd70aed
|
|||
|
a4de446f58
|
|||
| d21c35965d | |||
| 63ea58ffaa | |||
|
2ecace3e90
|
|||
| d5ac3958a4 | |||
| 72c20e0039 | |||
|
575f9905f1
|
|||
|
e3a0af5840
|
|||
|
9a3a39a185
|
|||
|
cea56dddde
|
|||
|
2c515ce70a
|
|||
| 40c0fb4bac | |||
| 0643df6d73 | |||
| e9b99fe921 | |||
| 4920dfedb3 | |||
| 0a3624c15b | |||
|
c5687dfbd7
|
|||
| ca91abbed9 | |||
| d4727db8f9 | |||
| af8cd1f67a | |||
|
cdd7516e54
|
|||
| 99e3ed416f | |||
| 02b726db02 | |||
| 2de6934322 | |||
|
cb49cf06d1
|
|||
|
9affda8a70
|
|||
| 3957b7c965 | |||
| 0d83339d80 | |||
|
6e54ec7213
|
|||
|
66b40a9189
|
|||
|
049f02f063
|
|||
|
15857e6453
|
|||
|
31e0ed75b0
|
|||
| b1d3fcbb0b | |||
| 7b6134f35e | |||
|
316b59b465
|
|||
|
92b073d5b6
|
|||
| 9b0dd933b5 | |||
| f255fa1555 | |||
| 74200318ab | |||
| 609656b4e1 | |||
|
856c9f2f7d
|
|||
| bd5cdd3443 | |||
| 79d274e074 | |||
| 51e3df17f1 | |||
| ccf0215495 | |||
|
254df7f2be
|
|||
|
6a673ef101
|
|||
|
7f7f7224c6
|
|||
|
f96bf9a8ac
|
|||
|
dcecf32999
|
|||
|
bc88dac150
|
|||
|
704c0e9c74
|
|||
|
c9bb7e15c2
|
|||
|
d90c9b88f1
|
|||
|
69ce07f81f
|
|||
| 85b90ef80c | |||
|
3e511446aa
|
|||
|
7566b4262b
|
|||
|
c249c6ae9c
|
|||
|
be693e9df0
|
|||
|
a43125701c
|
|||
|
b57edb440a
|
|||
|
6fc4573a71
|
|||
| cbe6676881 | |||
|
b4fd39828f
|
|||
|
14f2d72aba
|
|||
|
57692ec3c9
|
|||
|
47d3b77003
|
|||
|
8078e91e52
|
|||
|
dc5d3a8dd6
|
|||
|
ab6107610c
|
|||
|
e837835e00
|
|||
|
c646263e9e
|
|||
| 422c642949 | |||
|
379915587c
|
|||
|
970ae0fc4e
|
|||
|
d11ad61efb
|
|||
|
54dc696c69
|
|||
|
7e3ce9c42a
|
|||
| 7751423c7d | |||
|
f18f0b6f82
|
|||
|
892f6c0730
|
|||
|
b53fd2689c
|
|||
|
906bf65d47
|
|||
|
1e6a6e6174
|
|||
|
1e4f1b4ade
|
|||
|
306fe02d1c
|
|||
|
e4610f8ad5
|
|||
|
e1f900de14
|
|||
|
d5b18d74ef
|
|||
|
776a83d8d1
|
|||
|
810cea8269
|
|||
|
c0f3e6f2a4
|
|||
|
7b240059b0
|
|||
|
c456d13881
|
|||
|
c7c553164d
|
|||
|
7616528f4e
|
|||
|
6cd85f7239
|
|||
|
b1774cc44b
|
|||
|
e438fc6e8e
|
|||
|
c065ceb1f0
|
|||
|
ce4b775428
|
|||
|
d02f659bf8
|
|||
|
f3ded88ed8
|
|||
|
bf648eeb5d
|
|||
|
533edbf172
|
|||
|
78b8cf9725
|
|||
|
f0560ca975
|
|||
|
ce7b4733d7
|
|||
|
575bfbb0fb
|
|||
|
510ce66570
|
|||
|
82631d9ab1
|
|||
|
358490e939
|
|||
|
79b9cc9be7
|
|||
|
9b6eb613aa
|
|||
|
8f1231e409
|
|||
|
aa37c936eb
|
|||
|
3d1158a425
|
|||
|
8788558cf1
|
|||
|
76035e003e
|
|||
|
b708382d26
|
|||
|
557b670fc5
|
|||
|
e116148c49
|
|||
|
d5593b69e0
|
|||
|
0be532692d
|
|||
|
7a9224b2b2
|
|||
| e73d1a8359 | |||
|
f8c49c82c8
|
|||
|
ab7edd2a62
|
|||
|
b1888dcf0f
|
|||
| e5e122296f | |||
|
83bf148304
|
|||
| d80b882b83 | |||
| c345c6f5f1 | |||
|
f8c4fd72a3
|
|||
|
10f612f998
|
|||
| 58e78e4d7c | |||
| 25258d3d64 | |||
| b3bd058962 | |||
| b4fd7fd77c | |||
| 64cfdae6b7 | |||
| 0a765794f2 | |||
| 18dc6e9434 | |||
| 4ba4107288 | |||
|
d9b4f4ef3b
|
|||
| c365dcf96d | |||
| 0c6a7cc0b8 | |||
|
6640cfab64
|
|||
| 71addcd1b2 | |||
| 60c0e55e3d | |||
| e42139fd83 | |||
| 2d826e47d0 | |||
| 2db172ea5a | |||
| 2077658f6a | |||
| 502e26b534 | |||
| e22b692ada | |||
|
5ae73f700e
|
|||
|
63d419caae
|
|||
|
179b66d65c
|
|||
|
c9144d90f3
|
|||
|
ebf5d82c56
|
|||
|
8bb98ed0ed
|
|||
|
23f5745cb8
|
|||
|
2cd453ae8d
|
|||
|
e42cc0f91d
|
|||
| 1de45a6508 | |||
|
55c7aca3c0
|
|||
|
8fa9419c99
|
|||
|
73ad0a802e
|
|||
|
798fd2336c
|
|||
| 70e65d6667 | |||
|
efc9602808
|
|||
|
1e110f1375
|
|||
|
473cae0146
|
|||
|
2da859896a
|
|||
|
ab00578ee1
|
|||
|
3dc5662821
|
|||
|
ab64eb2e8d
|
|||
|
4f22228aab
|
|||
|
a7f1af7476
|
|||
| 949510d4c3 | |||
|
9f478dac1d
|
|||
|
69f38ea445
|
|||
|
0582147874
|
|||
|
bdeeb75973
|
|||
|
2518e65e3e
|
|||
|
8354c92654
|
|||
| 173e81b885 | |||
| d91731518b | |||
| 2bfee5058d | |||
| a7ce71d6cf | |||
| 10f60fee1d | |||
| 6025ab443f | |||
| 43ecf35449 | |||
| 4d2a1065d2 | |||
| 0b67500cab | |||
|
e0c3a06182
|
|||
| a86ba4e97b | |||
| b5b3395138 | |||
|
502b78ef5c
|
|||
| 3e2b4dae6a | |||
| 573fe403b3 | |||
|
76862e9d66
|
|||
| e8e337a608 | |||
| 500389c5f5 | |||
|
dea665652c
|
|||
| e8cf84b523 | |||
| fab25a6124 | |||
| e71377539c | |||
|
497ecf476a
|
|||
|
ff1c043ec5
|
|||
|
c4d2e297f8
|
|||
| e98b8e3666 | |||
| f5835fe404 | |||
| 07bbe9394f | |||
| 6974681af5 | |||
| 73250fb899 | |||
| 4ce377cffe | |||
| c7dd029689 | |||
| 51319d2ae2 | |||
| d1c2343a54 | |||
| 135ffde0e5 | |||
|
6e4dd51b27
|
|||
| 81b652718b | |||
|
442f46e17f
|
|||
|
574794d4e8
|
|||
| 88184125c4 | |||
| 8a4baa66ee | |||
| 16ecbd0291 | |||
| f65b262c11 | |||
| c5d9d88359 | |||
| 87e5909363 | |||
| 152c5d4563 | |||
| 34b274bc52 | |||
| 62f8103fc2 | |||
| 2dcbfa1d65 | |||
| 049da94629 | |||
| b2739dcdf2 | |||
|
343b2bfb91
|
|||
|
17aeed6dbd
|
|||
| 27cac81830 | |||
| 31ec322c55 | |||
| 18615eaaef | |||
| 5e508538f3 | |||
| 9e05000476 | |||
|
f088a0d327
|
|||
| 3832403c97 | |||
| 47058c897c | |||
| 5d4c7f8ef0 | |||
| ee4315adb3 | |||
| 9ade250f01 | |||
|
81b032be85
|
|||
| 5409990a68 | |||
| b1595c0ec9 | |||
|
6c99a2980b
|
|||
|
a9405a36c6
|
|||
| 15a417d9bd | |||
| 0ce8b3a5c2 | |||
| edff63b446 | |||
| d5979436c1 | |||
| cb33edaac3 | |||
| e9879e2226 | |||
| 5428ebf43b | |||
| d120299929 | |||
| 3753357ef8 | |||
| 611430aab2 | |||
| f56b02b951 | |||
|
f29278f80a
|
|||
| a9a294cbb7 | |||
|
73004789a4
|
|||
| 440aba77d5 | |||
| e4a89bcc4f | |||
|
eb07617e73
|
|||
|
9fca4e56fb
|
|||
|
f17523010a
|
|||
|
3058178d84
|
|||
|
d62c4e3400
|
|||
|
5739758c3a
|
|||
|
a6b5566fa6
|
|||
|
4dbe1362a8
|
|||
|
98fc36c830
|
|||
|
b8abc8705c
|
|||
|
636261934f
|
|||
|
6381b73a6a
|
|||
|
1a72e27045
|
|||
|
9754c1b2d1
|
|||
| b14ec0cda4 | |||
| c7730ba604 | |||
|
47c61df444
|
|||
|
312b93e794
|
|||
|
992e675921
|
|||
|
d4f3a7be31
|
|||
| d619f399e7 | |||
|
96a8cb7aff
|
|||
| 9b51d22c20 | |||
| d789830ce4 | |||
|
e4b4084dfd
|
|||
|
ff58646cfc
|
|||
|
eec6469ba1
|
|||
|
e94f947d20
|
|||
|
cccbe4a2ec
|
|||
|
f53cfb6c36
|
|||
|
f55f01a25c
|
|||
|
ce5c1a9ebb
|
|||
|
5e3b039f93
|
|||
|
0e9d218bbc
|
|||
|
e1c635af86
|
|||
|
f6b139dfea
|
|||
|
3d2b8fa446
|
|||
|
2eebac6fc0
|
|||
|
f5e2710138
|
|||
|
986470784d
|
|||
| e76ed771df | |||
|
f28af5e42f
|
|||
|
fdf4854b0c
|
|||
|
6b9512d09c
|
|||
|
21a86731d0
|
|||
|
91102e6607
|
|||
|
fadafda0b8
|
|||
|
c03cf76702
|
|||
|
ebb748b7e7
|
|||
|
2b3dbee24c
|
|||
|
a448cfdd0d
|
|||
| 5ee6eb53b2 | |||
| 7b2880d425 | |||
| 928d6f5d7f | |||
|
29fa607190
|
|||
|
7c541ffdfa
|
|||
|
7ccc4b4c08
|
|||
|
ef4df35995
|
|||
|
71a9155042
|
|||
|
2a88491d7c
|
|||
|
bf79552204
|
|||
|
0a7fa54759
|
|||
| 7c1a97be72 | |||
| f20fbbc913 | |||
| 76717531bd | |||
| 6774893412 | |||
| ebb86391af | |||
| 50db39424c | |||
| ca1ea32c46 | |||
| 32851d4d99 | |||
|
c47aa49373
|
|||
|
cdee6b00c4
|
|||
|
a3e9383a4a
|
|||
|
b4cce7dcf4
|
|||
|
b089109c94
|
|||
|
27e0708ac7
|
|||
|
a93786c6be
|
|||
|
521570224b
|
|||
|
c72462e0b6
|
|||
| 54646650c7 | |||
|
903aac9d7a
|
|||
| 49865c6a97 | |||
|
a694c8c20e
|
|||
|
d4a42d8378
|
|||
|
e16ca45fa7
|
|||
|
32de2ee5de
|
|||
|
834d41ef50
|
|||
|
6fe5aed408
|
|||
|
03041b88d0
|
|||
|
9338afb492
|
|||
| 313ae0dbe2 | |||
| 0dc7ec8570 | |||
|
8a1a3aeb12
|
|||
| 910469cfa0 | |||
| 4f055096e9 | |||
| 6c93f980dc | |||
| 57f52bbf33 | |||
| 9f5620d881 | |||
| 44c4555aae | |||
| 025d1e0a8c | |||
| f484021148 | |||
| 1403eac72c | |||
| a6e23938eb | |||
| cae0d9ef79 | |||
|
89fcb5b216
|
|||
| 56b3e9bb19 | |||
|
9aa4a98b0b
|
|||
|
5fbba0c934
|
|||
|
d772f4b2c6
|
|||
| 7513fbd57d | |||
| 9082761f86 | |||
| a3bd6e14d0 | |||
|
49dd702d98
|
|||
|
e4cd5e3efe
|
|||
| 1db4602020 | |||
| b50718050b | |||
| 9e39e1dc88 | |||
|
1a3a53cfc2
|
|||
|
5f53d591f8
|
|||
| d7013518cc | |||
| b204b289d1 | |||
|
3a0d9f7ed7
|
|||
|
e794c17fb4
|
|||
| e788ac21f6 | |||
|
4e78b060e0
|
|||
|
4fada9c1b7
|
|||
|
08d26e1a39
|
|||
|
581b28a2b1
|
|||
| e4d58849ce | |||
| 5e8b9d9bf7 | |||
| 11dd665794 | |||
| ba163e9bf3 | |||
| 09048ee223 | |||
| 19a055b59b | |||
| 1b28a07e17 | |||
| 82866cd213 | |||
|
47f3d2638b
|
|||
|
a3b894320a
|
|||
| 9424a58c52 | |||
| 1751ba534e | |||
| a21d431541 | |||
| 8fad34e430 | |||
|
a036de3c26
|
|||
| 4c2109e8ce | |||
|
7f745ff19f
|
|||
| 521d3d1259 | |||
|
14187449a5
|
|||
|
2037f4cc19
|
|||
| 05d492d30b | |||
| 9591e91ed6 | |||
|
f6f587e506
|
|||
| 4f28dbee87 | |||
| ad1cc038e3 | |||
| 15dbd85d25 | |||
| 2a97955586 | |||
|
9e44d1dfba
|
|||
| 87ad8e2761 | |||
| cfe703b15d | |||
| 96503fa9e9 | |||
|
07d49d8566
|
|||
|
5a7c25375a
|
|||
|
652143e76c
|
|||
|
8afce6eebf
|
|||
|
d3e6c9dc94
|
|||
| 4fd0ca3dd1 | |||
| dc0b6c2c8c | |||
| 54f242baf7 | |||
| 07620c7d89 | |||
| 1cae4cce4e | |||
|
9347ade82c
|
|||
|
3fa18a8050
|
|||
|
4ac67662a2
|
|||
|
d1be4077c5
|
|||
|
5a88c34a7c
|
|||
|
2e452e3213
|
|||
|
9d16a8e10c
|
|||
| 8755a6c3b4 | |||
| 8cee8ae33a | |||
| 15b138e026 | |||
| 4a8ed36dea | |||
| 7d0c3cc496 | |||
| 3cf479ffd5 | |||
| d402050a40 | |||
|
664edce09d
|
|||
|
e41caa891d
|
|||
|
42a6818ff4
|
|||
|
8f709c05bf
|
|||
|
a4ebf7befc
|
|||
|
8458e61d17
|
|||
|
b42d5bf113
|
|||
|
f684c6d6e4
|
|||
| 6593baf9f4 | |||
|
50123f3810
|
|||
|
d132e87f14
|
|||
| 37a1c3fb85 | |||
| c8183aa6d1 | |||
|
4711de29ae
|
|||
|
b719aaba41
|
|||
| 074c51b672 | |||
| 1aa6be704a | |||
|
e8e3cb8598
|
|||
|
85fec6b107
|
|||
| 12dbb061a9 | |||
| 351bd7d4ba | |||
|
cdc7037c25
|
|||
| 682237c98e | |||
|
08d97be43a
|
|||
|
786dfde27e
|
|||
| 6e012b910e | |||
| c153c5da2e | |||
|
0540e42168
|
|||
|
4bc95a5b52
|
|||
|
febc6e2874
|
|||
|
b2c990bf12
|
|||
|
3b8893502a
|
|||
| e0a0378f73 | |||
| 0837045d44 | |||
|
cd8137a7d8
|
|||
|
ece4537a2d
|
|||
|
16fe1b68c6
|
|||
| e37f235fd4 | |||
|
0423ce7e84
|
|||
|
d46ac22bd7
|
|||
|
cef5cd8611
|
|||
| 8b38dac9ab | |||
| 89fc875088 | |||
|
026a9ba2d7
|
|||
| 99f2b9c6dc | |||
|
578e91eeec
|
|||
|
49f79dbd45
|
|||
|
574d556bb9
|
|||
|
801aad64df
|
|||
|
b0a0829712
|
|||
| 6aae06c3ec | |||
| d0c6fa5b45 | |||
| c947354ee3 | |||
|
9b7e5752fb
|
|||
| 9bc51629d4 | |||
|
4ba15df9b7
|
|||
|
5721b357a2
|
|||
| 6140abbcac | |||
| 996255188b | |||
|
11d78234b2
|
|||
|
c214937e4a
|
|||
|
3a3f41988b
|
|||
|
f6690a80bd
|
|||
|
2337c4648b
|
|||
|
a1190f1352
|
|||
|
e421922f5b
|
|||
|
10d5705d1a
|
|||
|
a4f1634b24
|
|||
|
cbd924060f
|
|||
|
3c4bb6a55e
|
|||
|
a0d7a76f9d
|
|||
|
c71efb46ba
|
|||
|
ce69967ec5
|
|||
| 1a04439b1f | |||
|
979f417a63
|
|||
|
b27acb2f61
|
|||
|
622ecc4885
|
|||
|
ed5bbda811
|
|||
|
7b627ea518
|
|||
|
1ac66da83f
|
|||
| 061de96b62 | |||
|
6998298d32
|
|||
|
323f4467c8
|
|||
|
e8e41850b5
|
|||
|
0e23ec53d7
|
|||
|
b943a8b9b1
|
|||
|
acc665f054
|
|||
| 860f1d6376 | |||
| 2122f0e67c | |||
| 6aa23a76a1 | |||
|
338360096c
|
|||
|
7a8c7cd50f
|
|||
|
bafc8a8e34
|
|||
|
3d44d8c9fd
|
|||
|
b8b4616498
|
|||
| da97117929 | |||
| 978297c464 | |||
| 11da4808fc | |||
|
4023e6a066
|
|||
|
f432bfdd23
|
|||
| 848e17578d | |||
|
1615130929
|
|||
|
7f315315f0
|
|||
|
6a50981120
|
|||
|
c67471e6ca
|
|||
|
f0fc1027e5
|
|||
|
c66695d55e
|
|||
|
262009701e
|
|||
|
b31cb6b866
|
|||
|
f39e186b66
|
|||
|
a8f35bdf2f
|
|||
|
6e1e02ac28
|
|||
|
16fc5ee54b
|
|||
|
37a1fcc4af
|
|||
|
a9b522719f
|
|||
|
ce70932a1c
|
|||
|
d61e104536
|
|||
|
d5f30a3ae4
|
|||
|
2555096510
|
|||
|
3797292b20
|
|||
|
6333815b71
|
|||
|
793a850fd5
|
|||
|
42c1450384
|
|||
|
a2377882f6
|
|||
|
e78b395662
|
|||
|
cdec834ca9
|
|||
|
b4b0b464bd
|
|||
|
d8a1b0ccc1
|
|||
|
3fbd381f55
|
|||
|
d3e127e5c8
|
|||
|
e9cfb076c6
|
|||
|
8ccf856110
|
|||
|
d0945aa09d
|
|||
|
123619219e
|
|||
|
a27410952e
|
|||
| 13e0392af6 | |||
| 99a6135f72 | |||
|
a6b52c1354
|
|||
| fa51459191 | |||
|
c529988427
|
|||
|
231cc3c718
|
|||
|
3381b8936d
|
|||
|
823f869f1d
|
|||
|
ecbeacf10f
|
|||
|
3f838038d5
|
|||
| 91b4e021d0 | |||
| 598e87dca2 | |||
| 001511876d | |||
| b295958c17 | |||
|
2fbdcfb958
|
|||
|
09ac74d205
|
|||
|
5da4afa0ec
|
|||
|
9d5e805748
|
|||
| 770ae5ed9b | |||
|
e056d8dc44
|
|||
|
c3442354e7
|
|||
|
6b2a0011af
|
|||
|
46fca7cfa7
|
|||
|
82d560a946
|
|||
|
fc5107865b
|
|||
|
53ed1fc545
|
|||
|
cc9e3d4e60
|
|||
| 0557284461 | |||
| b5f23d3791 | |||
|
2b2dcc01b4
|
|||
|
0a208d049e
|
|||
|
141711ecd0
|
|||
| cd46d71ce4 | |||
| 6fa090352d | |||
|
227c02cd09
|
|||
|
bfeda40e34
|
|||
|
5237c7ed50
|
|||
|
4e09f3b9a8
|
|||
|
dfb32cbb68
|
|||
|
bdd9b0a1aa
|
|||
|
b2d17a1829
|
|||
|
c905376472
|
|||
|
d316de218c
|
|||
|
123475bd36
|
|||
|
58e98f490d
|
|||
|
224b8865bf
|
|||
|
8fb9f42f13
|
|||
|
dc5e2a5b24
|
|||
|
40b4ef5ab2
|
|||
|
4a912ae3bc
|
|||
|
1150fcc595
|
|||
|
45224d1349
|
|||
|
7a40e2d616
|
|||
|
2277e4ef72
|
|||
|
c0c3d9fe76
|
|||
|
2493921ade
|
|||
|
22f9cf2be4
|
|||
| a23124aede | |||
| e670844b56 | |||
|
bc1729c5ca
|
|||
|
fa8611b115
|
|||
|
415df981ff
|
|||
|
57728e58e8
|
|||
|
c7062e0494
|
|||
|
cff7534bf9
|
|||
|
13e582349c
|
|||
|
b1b9612e01
|
|||
|
afeee1270e
|
|||
|
cb210d0c81
|
|||
|
9f2bb3f74f
|
|||
|
a33767f848
|
|||
|
a1abe5c6be
|
|||
|
672b44f965
|
|||
|
6d9573ec7e
|
|||
|
53cd3b8b71
|
|||
|
b9ec41647b
|
|||
|
f4b563528f
|
|||
|
f9a2c1d58f
|
|||
|
7a66a90ecb
|
|||
|
0e688f1407
|
|||
|
c6db9ee355
|
|||
|
7733637767
|
|||
|
88f9796aaf
|
|||
|
6cdba0f9de
|
|||
|
199aa5f4e3
|
|||
|
9b26c24a5f
|
|||
|
ca75654769
|
|||
|
fc2d83d203
|
|||
|
2f4f288a46
|
|||
|
e98f00d354
|
|||
| b4c2773b87 | |||
|
3aec5d1d7e
|
|||
|
e0fa1b6995
|
|||
|
b69ab0df65
|
|||
|
69a7d37fb7
|
|||
|
87649cbbd0
|
|||
|
4b7ec6384c
|
|||
|
b22b63c2ba
|
|||
|
d9f3a11265
|
|||
|
d7cf11b876
|
|||
|
d7e1b2947a
|
|||
|
1b37d2d5f5
|
|||
|
74dfb12fd6
|
|||
|
49ccf2d204
|
|||
|
76adc45431
|
|||
|
e38a0078f3
|
|||
|
25b44dc54e
|
|||
|
0c2f6fb676
|
|||
|
10e4a8b97f
|
|||
|
eed2756784
|
|||
|
b61b8f0d2a
|
|||
|
763e7b5bff
|
|||
|
d5ab9aedbf
|
|||
|
2ebb00c9d4
|
|||
|
6d76b3646a
|
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
*.swo
|
||||
*.swp
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
abra
|
||||
dist
|
||||
kadabra
|
||||
tags
|
||||
112
.drone.yml
112
.drone.yml
@ -3,45 +3,20 @@ kind: pipeline
|
||||
name: coopcloud.tech/abra
|
||||
steps:
|
||||
- name: make check
|
||||
image: golang:1.17
|
||||
image: golang:1.24
|
||||
commands:
|
||||
- make check
|
||||
|
||||
- name: make static
|
||||
image: golang:1.17
|
||||
ignore: true # until we decide we all want this check
|
||||
environment:
|
||||
STATIC_CHECK_URL: honnef.co/go/tools/cmd/staticcheck
|
||||
STATIC_CHECK_VERSION: v0.2.0
|
||||
commands:
|
||||
- go install $STATIC_CHECK_URL@$STATIC_CHECK_VERSION
|
||||
- make static
|
||||
|
||||
- name: make build
|
||||
image: golang:1.17
|
||||
commands:
|
||||
- make build
|
||||
|
||||
- name: make test
|
||||
image: golang:1.17
|
||||
image: golang:1.24
|
||||
environment:
|
||||
CATL_URL: https://git.coopcloud.tech/toolshed/recipes-catalogue-json.git
|
||||
commands:
|
||||
- mkdir -p $HOME/.abra
|
||||
- git clone $CATL_URL $HOME/.abra/catalogue
|
||||
- make test
|
||||
|
||||
- name: notify on failure
|
||||
image: plugins/matrix
|
||||
settings:
|
||||
homeserver: https://matrix.autonomic.zone
|
||||
roomid: "IFazIpLtxiScqbHqoa:autonomic.zone"
|
||||
userid: "@autono-bot:autonomic.zone"
|
||||
accesstoken:
|
||||
from_secret: autono_bot_access_token
|
||||
depends_on:
|
||||
- make check
|
||||
- make build
|
||||
- make test
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
|
||||
- name: fetch
|
||||
image: docker:git
|
||||
@ -49,13 +24,12 @@ steps:
|
||||
- git fetch --tags
|
||||
depends_on:
|
||||
- make check
|
||||
- make build
|
||||
- make test
|
||||
when:
|
||||
event: tag
|
||||
|
||||
- name: release
|
||||
image: golang:1.17
|
||||
image: goreleaser/goreleaser:v2.5.1
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: goreleaser_gitea_token
|
||||
@ -63,12 +37,82 @@ steps:
|
||||
- name: deps
|
||||
path: /go
|
||||
commands:
|
||||
- curl -sL https://git.io/goreleaser | bash
|
||||
- goreleaser release
|
||||
depends_on:
|
||||
- fetch
|
||||
when:
|
||||
event: tag
|
||||
|
||||
- name: publish image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
auto_tag: true
|
||||
username: abra-bot
|
||||
password:
|
||||
from_secret: git_coopcloud_tech_token_abra_bot
|
||||
repo: git.coopcloud.tech/toolshed/abra
|
||||
tags: dev
|
||||
registry: git.coopcloud.tech
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
depends_on:
|
||||
- make check
|
||||
- make test
|
||||
|
||||
- name: on-demand integration test
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host:
|
||||
- int.coopcloud.tech
|
||||
username: abra
|
||||
key:
|
||||
from_secret: abra_int_private_key
|
||||
port: 22
|
||||
command_timeout: 60m
|
||||
script_stop: true
|
||||
request_pty: true
|
||||
script:
|
||||
- |
|
||||
wget https://git.coopcloud.tech/toolshed/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
|
||||
chmod +x run-ci-int
|
||||
sh run-ci-int
|
||||
when:
|
||||
ref:
|
||||
- refs/heads/int-*
|
||||
depends_on:
|
||||
- make check
|
||||
- make test
|
||||
|
||||
- name: nightly integration test
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host:
|
||||
- int.coopcloud.tech
|
||||
username: abra
|
||||
key:
|
||||
from_secret: abra_int_private_key
|
||||
port: 22
|
||||
command_timeout: 60m
|
||||
script_stop: true
|
||||
request_pty: true
|
||||
script:
|
||||
- |
|
||||
wget https://git.coopcloud.tech/toolshed/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
|
||||
chmod +x run-ci-int
|
||||
sh run-ci-int
|
||||
when:
|
||||
event:
|
||||
- cron:
|
||||
cron:
|
||||
# @daily https://docs.drone.io/cron/
|
||||
- integration
|
||||
|
||||
volumes:
|
||||
- name: deps
|
||||
temp: {}
|
||||
|
||||
trigger:
|
||||
action:
|
||||
exclude:
|
||||
- synchronized
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
GANDI_TOKEN=...
|
||||
HCLOUD_TOKEN=...
|
||||
REGISTRY_PASSWORD=...
|
||||
REGISTRY_USERNAME=...
|
||||
@ -1,6 +1,7 @@
|
||||
go env -w GOPRIVATE=coopcloud.tech
|
||||
# integration test suite
|
||||
# export ABRA_DIR="$HOME/.abra_test"
|
||||
# export TEST_SERVER=test.example.com
|
||||
# export ABRA_CI=1
|
||||
|
||||
# export PASSWORD_STORE_DIR=$(pwd)/../../autonomic/passwords/passwords/
|
||||
# export HCLOUD_TOKEN=$(pass show logins/hetzner/cicd/api_key)
|
||||
# export CAPSUL_TOKEN=...
|
||||
# export GITEA_TOKEN=...
|
||||
# release automation
|
||||
# export GITEA_TOKEN=
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
---
|
||||
name: "Do not use this issue tracker"
|
||||
about: "Do not use this issue tracker"
|
||||
title: "Do not use this issue tracker"
|
||||
labels: []
|
||||
---
|
||||
|
||||
Please report your issue on [`coop-cloud/organising`](https://git.coopcloud.tech/coop-cloud/organising)
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
.e2e.env
|
||||
.envrc
|
||||
.vscode/
|
||||
abra
|
||||
/abra
|
||||
/kadabra
|
||||
dist/
|
||||
vendor/
|
||||
tests/integration/.bats
|
||||
|
||||
@ -1,38 +1,76 @@
|
||||
---
|
||||
project_name: abra
|
||||
gitea_urls:
|
||||
api: https://git.coopcloud.tech/api/v1
|
||||
download: https://git.coopcloud.tech/
|
||||
skip_tls_verify: false
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
- id: abra
|
||||
binary: abra
|
||||
dir: cmd/abra
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- 386
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
ldflags:
|
||||
- "-X 'main.Commit={{ .Commit }}'"
|
||||
- "-X 'main.Version={{ .Version }}'"
|
||||
archives:
|
||||
- replacements:
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
format: binary
|
||||
- "-s"
|
||||
- "-w"
|
||||
|
||||
- id: kadabra
|
||||
binary: kadabra
|
||||
dir: cmd/kadabra
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- 386
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
gcflags:
|
||||
- "all=-l -B"
|
||||
ldflags:
|
||||
- "-X 'main.Commit={{ .Commit }}'"
|
||||
- "-X 'main.Version={{ .Version }}'"
|
||||
- "-s"
|
||||
- "-w"
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: desc
|
||||
filters:
|
||||
exclude:
|
||||
- "^Merge"
|
||||
- "^Revert"
|
||||
- "^WIP:"
|
||||
- "^chore(deps):"
|
||||
- "^style:"
|
||||
- "^test:"
|
||||
- "^tests:"
|
||||
- "^Revert"
|
||||
|
||||
22
AUTHORS.md
Normal file
22
AUTHORS.md
Normal file
@ -0,0 +1,22 @@
|
||||
# authors
|
||||
|
||||
> If you're looking at this and you hack on `abra` and you're not listed here,
|
||||
> please do add yourself! This is a community project, let's show some 💞
|
||||
|
||||
- 3wordchant
|
||||
- ammaratef45
|
||||
- cassowary
|
||||
- codegod100
|
||||
- decentral1se
|
||||
- fauno
|
||||
- frando
|
||||
- kawaiipunk
|
||||
- knoflook
|
||||
- moritz
|
||||
- p4u1
|
||||
- rix
|
||||
- roxxers
|
||||
- vera
|
||||
- yksflip
|
||||
- basebuilder
|
||||
- mayel
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
||||
# Build image
|
||||
FROM golang:1.24-alpine AS build
|
||||
|
||||
ENV GOPRIVATE=coopcloud.tech
|
||||
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
git \
|
||||
make \
|
||||
musl-dev
|
||||
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN CGO_ENABLED=0 make build
|
||||
|
||||
# Release image ("slim")
|
||||
FROM alpine:3.19.1
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
git \
|
||||
openssh
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /app/abra /abra
|
||||
|
||||
ENTRYPOINT ["/abra"]
|
||||
15
LICENSE
Normal file
15
LICENSE
Normal file
@ -0,0 +1,15 @@
|
||||
Abra: The Co-op Cloud utility belt
|
||||
Copyright (C) 2022 Co-op Cloud <helo@coopcloud.tech>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
76
Makefile
76
Makefile
@ -1,35 +1,55 @@
|
||||
ABRA := ./cmd/abra
|
||||
COMMIT := $(shell git rev-list -1 HEAD)
|
||||
GOPATH := $(shell go env GOPATH)
|
||||
LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
|
||||
ABRA := ./cmd/abra
|
||||
KADABRA := ./cmd/kadabra
|
||||
COMMIT := $(shell git rev-list -1 HEAD)
|
||||
GOPATH := $(shell go env GOPATH)
|
||||
GOVERSION := 1.24
|
||||
LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
|
||||
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
|
||||
GCFLAGS := "all=-l -B"
|
||||
|
||||
export GOPRIVATE=coopcloud.tech
|
||||
|
||||
all: format check static build test
|
||||
# NOTE(d1): default `make` optimised for Abra hacking
|
||||
all: format check build-abra test
|
||||
|
||||
run:
|
||||
@go run -ldflags=$(LDFLAGS) $(ABRA)
|
||||
run-abra:
|
||||
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
|
||||
|
||||
install:
|
||||
@go install -ldflags=$(LDFLAGS) $(ABRA)
|
||||
run-kadabra:
|
||||
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
|
||||
|
||||
build-dev:
|
||||
@go build -ldflags=$(LDFLAGS) $(ABRA)
|
||||
install-abra:
|
||||
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
|
||||
|
||||
build:
|
||||
@go build -ldflags=$(DIST_LDFLAGS) $(ABRA)
|
||||
install-kadabra:
|
||||
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
|
||||
|
||||
install: install-abra install-kadabra
|
||||
|
||||
build-abra:
|
||||
@go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(ABRA)
|
||||
|
||||
build-kadabra:
|
||||
@go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(KADABRA)
|
||||
|
||||
build: build-abra build-kadabra
|
||||
|
||||
build-docker-abra:
|
||||
@docker run -it -v $(PWD):/abra golang:$(GOVERSION) \
|
||||
bash -c 'cd /abra; ./scripts/docker/build.sh'
|
||||
|
||||
build-docker: build-docker-abra
|
||||
|
||||
clean:
|
||||
@rm '$(GOPATH)/bin/abra'
|
||||
@rm '$(GOPATH)/bin/kadabra'
|
||||
|
||||
format:
|
||||
@gofmt -s -w .
|
||||
@gofmt -s -w $$(find . -type f -name '*.go' | grep -v "/vendor/")
|
||||
|
||||
check:
|
||||
@test -z $$(gofmt -l .) || (echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
||||
|
||||
static:
|
||||
@staticcheck $(ABRA)
|
||||
@test -z $$(gofmt -l $$(find . -type f -name '*.go' | grep -v "/vendor/")) || \
|
||||
(echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
||||
|
||||
test:
|
||||
@go test ./... -cover -v
|
||||
@ -37,21 +57,5 @@ test:
|
||||
loc:
|
||||
@find . -name "*.go" | xargs wc -l
|
||||
|
||||
loc-author:
|
||||
@git ls-files -z | \
|
||||
xargs -0rn 1 -P "$$(nproc)" -I{} sh -c 'git blame -w -M -C -C --line-porcelain -- {} | grep -I --line-buffered "^author "' | \
|
||||
sort -f | \
|
||||
uniq -ic | \
|
||||
sort -n
|
||||
|
||||
int-core:
|
||||
@docker run \
|
||||
-v $$(pwd):/src \
|
||||
--env-file .e2e.env \
|
||||
debian:bullseye-slim \
|
||||
sh -c "\
|
||||
apt update && apt install -y wget curl git; echo ""; echo ""; \
|
||||
git config --global user.email 'e2e@coopcloud.tech'; \
|
||||
git config --global user.name 'e2e'; \
|
||||
cd /src/tests/integration && bash core.sh -- --dev \
|
||||
"
|
||||
deps:
|
||||
@go get -t -u ./...
|
||||
|
||||
76
README.md
76
README.md
@ -1,75 +1,13 @@
|
||||
# abra
|
||||
# `abra`
|
||||
|
||||
> https://coopcloud.tech
|
||||
|
||||
[](https://build.coopcloud.tech/coop-cloud/abra)
|
||||
[](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/abra)
|
||||
[](https://build.coopcloud.tech/toolshed/abra)
|
||||
[](https://goreportcard.com/report/git.coopcloud.tech/toolshed/abra)
|
||||
[](https://pkg.go.dev/coopcloud.tech/abra)
|
||||
|
||||
The Co-op Cloud utility belt 🎩🐇
|
||||
|
||||
`abra` is a command-line tool for managing your own [Co-op Cloud](https://coopcloud.tech). It can provision new servers, create apps, deploy them, run backup and restore operations and a whole lot of other things. Please see [docs.coopcloud.tech](https://docs.coopcloud.tech) for more extensive documentation.
|
||||
<a href="https://github.com/egonelbre/gophers"><img align="right" width="150" src="https://github.com/egonelbre/gophers/raw/master/.thumb/sketch/adventure/poking-fire.png"/></a>
|
||||
|
||||
## Quick install
|
||||
`abra` is the flagship client & command-line tool for Co-op Cloud. It has been developed specifically for the purpose of making the day-to-day operations of [operators](https://docs.coopcloud.tech/operators/) and [maintainers](https://docs.coopcloud.tech/maintainers/) pleasant & convenient. It is libre software, written in [Go](https://go.dev) and maintained and extended by the community 💖
|
||||
|
||||
```bash
|
||||
curl https://install.abra.autonomic.zone | bash
|
||||
```
|
||||
|
||||
Or using the latest release candidate (extra experimental!):
|
||||
|
||||
```bash
|
||||
curl https://install.abra.autonomic.zone | bash -s -- --rc
|
||||
```
|
||||
|
||||
Source for this script is in [scripts/installer/installer](./scripts/installer/installer).
|
||||
|
||||
## Hacking
|
||||
|
||||
### Getting started
|
||||
|
||||
Install [direnv](https://direnv.net), run `cp .envrc.sample .envrc`, then run `direnv allow` in this directory. This will set coopcloud repos as private due to [this bug.](https://git.coopcloud.tech/coop-cloud/coopcloud.tech/issues/20#issuecomment-8201). Or you can run `go env -w GOPRIVATE=coopcloud.tech` but I'm not sure how persistent this is.
|
||||
|
||||
Install [Go >= 1.16](https://golang.org/doc/install) and then:
|
||||
|
||||
- `make build` to build
|
||||
- `./abra` to run commands
|
||||
- `make test` will run tests
|
||||
- `make install` will install it to `$GOPATH/bin`
|
||||
- `go get <package>` and `go mod tidy` to add a new dependency
|
||||
|
||||
Our [Drone CI configuration](.drone.yml) runs a number of sanity on each pushed commit. See the [Makefile](./Makefile) for more handy targets.
|
||||
|
||||
Please use the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/) for your commits so we can automate our change log.
|
||||
|
||||
### Versioning
|
||||
|
||||
We use [goreleaser](https://goreleaser.com) to help us automate releases. We use [semver](https://semver.org) for versioning all releases of the tool. While we are still in the public alpha release phase, we will maintain a `0.y.z-alpha` format. Change logs are generated from our commit logs. We are still working this out and aim to refine our release praxis as we go.
|
||||
|
||||
For developers, while using this `-alpha` format, the `y` part is the "major" version part. So, if you make breaking changes, you increment that and _not_ the `x` part. So, if you're on `0.1.0-alpha`, then you'd go to `0.1.1-alpha` for a backwards compatible change and `0.2.0-alpha` for a backwards incompatible change.
|
||||
|
||||
### Making a new release
|
||||
|
||||
- Change `ABRA_VERSION` to match the new tag in [`scripts`](./scripts/installer/installer) (use [semver](https://semver.org))
|
||||
- Commit that change (e.g. `git commit -m 'chore: publish next tag x.y.z-alpha'`)
|
||||
- Make a new tag (e.g. `git tag -a x.y.z-alpha`)
|
||||
- Push the new tag (e.g. `git push && git push --tags`)
|
||||
- Wait until the build finishes on [build.coopcloud.tech](https://build.coopcloud.tech/coop-cloud/abra)
|
||||
- Deploy the new installer script (e.g. `cd ./scripts/installer && make`)
|
||||
- Check the release worked, (e.g. `abra upgrade; abra -v`)
|
||||
|
||||
### Fork maintenance
|
||||
|
||||
#### `godotenv`
|
||||
|
||||
We maintain a fork of [godotenv](https://github.com/Autonomic-Cooperative/godotenv) for two features:
|
||||
|
||||
1. multi-line env var support
|
||||
2. inline comment parsing
|
||||
|
||||
You can upgrade the version here by running `go get github.com/Autonomic-Cooperative/godotenv@<commit>` where `<commit>` is the
|
||||
latest commit you want to pin to. We are aiming to migrate to YAML format for the environment configuration, so this should only
|
||||
be a temporary thing.
|
||||
|
||||
#### `docker/client`
|
||||
|
||||
A number of modules in [pkg/upstream](./pkg/upstream) are copy/pasta'd from the upstream [docker/docker/client](https://pkg.go.dev/github.com/docker/docker/client). We had to do this because upstream are not exposing their API as public.
|
||||
Please see [docs.coopcloud.tech/abra](https://docs.coopcloud.tech/abra) for help on install, upgrade, hacking, troubleshooting & more!
|
||||
|
||||
@ -1,40 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AppCommand defines the `abra app` command and ets subcommands
|
||||
var AppCommand = &cli.Command{
|
||||
Name: "app",
|
||||
Usage: "Manage apps",
|
||||
Aliases: []string{"a"},
|
||||
ArgsUsage: "<app>",
|
||||
Description: `
|
||||
This command provides all the functionality you need to manage the life cycle
|
||||
of your apps. From initial deployment, day-2 operations (e.g. backup/restore)
|
||||
to scaling apps up and spinning them down.
|
||||
`,
|
||||
Subcommands: []*cli.Command{
|
||||
appNewCommand,
|
||||
appConfigCommand,
|
||||
appRestartCommand,
|
||||
appDeployCommand,
|
||||
appUpgradeCommand,
|
||||
appUndeployCommand,
|
||||
appBackupCommand,
|
||||
appRestoreCommand,
|
||||
appRemoveCommand,
|
||||
appCheckCommand,
|
||||
appListCommand,
|
||||
appPsCommand,
|
||||
appLogsCommand,
|
||||
appCpCommand,
|
||||
appRunCommand,
|
||||
appRollbackCommand,
|
||||
appSecretCommand,
|
||||
appVolumeCommand,
|
||||
appVersionCommand,
|
||||
appErrorsCommand,
|
||||
},
|
||||
var AppCommand = &cobra.Command{
|
||||
Use: "app [cmd] [args] [flags]",
|
||||
Aliases: []string{"a"},
|
||||
Short: gotext.Get("Manage apps"),
|
||||
}
|
||||
|
||||
@ -1,77 +1,307 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var backupAllServices bool
|
||||
var backupAllServicesFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Value: false,
|
||||
Destination: &backupAllServices,
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Backup all services",
|
||||
}
|
||||
|
||||
var appBackupCommand = &cli.Command{
|
||||
Name: "backup",
|
||||
Usage: "Backup an app",
|
||||
Aliases: []string{"b"},
|
||||
Flags: []cli.Flag{backupAllServicesFlag},
|
||||
ArgsUsage: "<service>",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Get(1) != "" && backupAllServices {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together"))
|
||||
}
|
||||
|
||||
abraSh := path.Join(config.RECIPES_DIR, app.Type, "abra.sh")
|
||||
if _, err := os.Stat(abraSh); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist?", abraSh)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
sourceCmd := fmt.Sprintf("source %s", abraSh)
|
||||
|
||||
execCmd := "abra_backup"
|
||||
if !backupAllServices {
|
||||
serviceName := c.Args().Get(1)
|
||||
if serviceName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
|
||||
}
|
||||
execCmd = fmt.Sprintf("abra_backup_%s", serviceName)
|
||||
}
|
||||
|
||||
bytes, err := ioutil.ReadFile(abraSh)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(bytes), execCmd) {
|
||||
logrus.Fatalf("%s doesn't have a %s function", app.Type, execCmd)
|
||||
}
|
||||
|
||||
sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
|
||||
cmd := exec.Command("bash", "-c", sourceAndExec)
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
var AppBackupListCommand = &cobra.Command{
|
||||
Use: "list <domain> [flags]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List the contents of a snapshot",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
|
||||
if snapshot != "" {
|
||||
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
|
||||
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
|
||||
}
|
||||
|
||||
if showAllPaths {
|
||||
log.Debugf("including SHOW_ALL=%v in backupbot exec invocation", showAllPaths)
|
||||
execEnv = append(execEnv, fmt.Sprintf("SHOW_ALL=%v", showAllPaths))
|
||||
}
|
||||
|
||||
if timestamps {
|
||||
log.Debugf("including TIMESTAMPS=%v in backupbot exec invocation", timestamps)
|
||||
execEnv = append(execEnv, fmt.Sprintf("TIMESTAMPS=%v", timestamps))
|
||||
}
|
||||
|
||||
if _, err = internal.RunBackupCmdRemote(cl, "ls", targetContainer.ID, execEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
var AppBackupDownloadCommand = &cobra.Command{
|
||||
Use: "download <domain> [flags]",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Download a snapshot",
|
||||
Long: `Downloads a backup.tar.gz to the current working directory.
|
||||
|
||||
"--volumes/-v" includes data contained in volumes alongide paths specified in
|
||||
"backupbot.backup.path" labels.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
|
||||
if snapshot != "" {
|
||||
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
|
||||
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
|
||||
}
|
||||
|
||||
if includePath != "" {
|
||||
log.Debugf("including INCLUDE_PATH=%s in backupbot exec invocation", includePath)
|
||||
execEnv = append(execEnv, fmt.Sprintf("INCLUDE_PATH=%s", includePath))
|
||||
}
|
||||
|
||||
if includeSecrets {
|
||||
log.Debugf("including SECRETS=%v in backupbot exec invocation", includeSecrets)
|
||||
execEnv = append(execEnv, fmt.Sprintf("SECRETS=%v", includeSecrets))
|
||||
}
|
||||
|
||||
if includeVolumes {
|
||||
log.Debugf("including VOLUMES=%v in backupbot exec invocation", includeVolumes)
|
||||
execEnv = append(execEnv, fmt.Sprintf("VOLUMES=%v", includeVolumes))
|
||||
}
|
||||
|
||||
if _, err := internal.RunBackupCmdRemote(cl, "download", targetContainer.ID, execEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
remoteBackupDir := "/tmp/backup.tar.gz"
|
||||
currentWorkingDir := "."
|
||||
if err = CopyFromContainer(cl, targetContainer.ID, remoteBackupDir, currentWorkingDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var AppBackupCreateCommand = &cobra.Command{
|
||||
Use: "create <domain> [flags]",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Create a new snapshot",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
|
||||
if retries != "" {
|
||||
log.Debugf("including RETRIES=%s in backupbot exec invocation", retries)
|
||||
execEnv = append(execEnv, fmt.Sprintf("RETRIES=%s", retries))
|
||||
}
|
||||
|
||||
if _, err := internal.RunBackupCmdRemote(cl, "create", targetContainer.ID, execEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var AppBackupSnapshotsCommand = &cobra.Command{
|
||||
Use: "snapshots <domain> [flags]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "List all snapshots",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
|
||||
if _, err = internal.RunBackupCmdRemote(cl, "snapshots", targetContainer.ID, execEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var AppBackupCommand = &cobra.Command{
|
||||
Use: "backup [cmd] [args] [flags]",
|
||||
Aliases: []string{"b"},
|
||||
Short: "Manage app backups",
|
||||
}
|
||||
|
||||
var (
|
||||
snapshot string
|
||||
retries string
|
||||
includePath string
|
||||
showAllPaths bool
|
||||
timestamps bool
|
||||
includeSecrets bool
|
||||
includeVolumes bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppBackupListCommand.Flags().StringVarP(
|
||||
&snapshot,
|
||||
"snapshot",
|
||||
"s",
|
||||
"",
|
||||
"list specific snapshot",
|
||||
)
|
||||
|
||||
AppBackupListCommand.Flags().BoolVarP(
|
||||
&showAllPaths,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"show all paths",
|
||||
)
|
||||
|
||||
AppBackupListCommand.Flags().BoolVarP(
|
||||
×tamps,
|
||||
"timestamps",
|
||||
"t",
|
||||
false,
|
||||
"include timestamps",
|
||||
)
|
||||
|
||||
AppBackupDownloadCommand.Flags().StringVarP(
|
||||
&snapshot,
|
||||
"snapshot",
|
||||
"s",
|
||||
"",
|
||||
"list specific snapshot",
|
||||
)
|
||||
|
||||
AppBackupDownloadCommand.Flags().StringVarP(
|
||||
&includePath,
|
||||
"path",
|
||||
"p",
|
||||
"",
|
||||
"volumes path",
|
||||
)
|
||||
|
||||
AppBackupDownloadCommand.Flags().BoolVarP(
|
||||
&includeSecrets,
|
||||
"secrets",
|
||||
"S",
|
||||
false,
|
||||
"include secrets",
|
||||
)
|
||||
|
||||
AppBackupDownloadCommand.Flags().BoolVarP(
|
||||
&includeVolumes,
|
||||
"volumes",
|
||||
"v",
|
||||
false,
|
||||
"include volumes",
|
||||
)
|
||||
|
||||
AppBackupDownloadCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppBackupCreateCommand.Flags().StringVarP(
|
||||
&retries,
|
||||
"retries",
|
||||
"r",
|
||||
"1",
|
||||
"number of retry attempts",
|
||||
)
|
||||
|
||||
AppBackupCreateCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
120
cli/app/check.go
120
cli/app/check.go
@ -1,53 +1,91 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appCheckCommand = &cli.Command{
|
||||
Name: "check",
|
||||
Usage: "Check if app is configured correctly",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<service>",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
var AppCheckCommand = &cobra.Command{
|
||||
Use: "check <domain> [flags]",
|
||||
Aliases: []string{"chk"},
|
||||
Short: "Ensure an app is well configured",
|
||||
Long: `Compare env vars in both the app ".env" and recipe ".env.sample" file.
|
||||
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, app.Type, ".env.sample")
|
||||
if _, err := os.Stat(envSamplePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist?", envSamplePath)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
The goal is to ensure that recipe ".env.sample" env vars are defined in your
|
||||
app ".env" file. Only env var definitions in the ".env.sample" which are
|
||||
uncommented, e.g. "FOO=bar" are checked. If an app ".env" file does not include
|
||||
these env vars, then "check" will complain.
|
||||
|
||||
envSample, err := config.ReadEnv(envSamplePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for k := range envSample {
|
||||
if _, ok := app.Env[k]; !ok {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
missingEnvVars := strings.Join(missing, ", ")
|
||||
logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars)
|
||||
}
|
||||
|
||||
logrus.Infof("all necessary environment variables defined for %s", app.Name)
|
||||
|
||||
return nil
|
||||
Recipe maintainers may or may not provide defaults for env vars within their
|
||||
recipes regardless of commenting or not (e.g. through the use of
|
||||
${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table.
|
||||
Headers(
|
||||
fmt.Sprintf("%s .env.sample", app.Recipe.Name),
|
||||
fmt.Sprintf("%s.env", app.Name),
|
||||
).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
switch {
|
||||
case col == 1:
|
||||
return lipgloss.NewStyle().Padding(0, 1, 0, 1).Align(lipgloss.Center)
|
||||
default:
|
||||
return lipgloss.NewStyle().Padding(0, 1, 0, 1)
|
||||
}
|
||||
})
|
||||
|
||||
envVars, err := appPkg.CheckEnv(app)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
if envVar.Present {
|
||||
val := []string{envVar.Name, "✅"}
|
||||
table.Row(val...)
|
||||
} else {
|
||||
val := []string{envVar.Name, "❌"}
|
||||
table.Row(val...)
|
||||
}
|
||||
}
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppCheckCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
276
cli/app/cmd.go
Normal file
276
cli/app/cmd.go
Normal file
@ -0,0 +1,276 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AppCmdCommand = &cobra.Command{
|
||||
Use: "command <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- [args]]",
|
||||
Aliases: []string{"cmd"},
|
||||
Short: "Run app commands",
|
||||
Long: `Run an app specific command.
|
||||
|
||||
These commands are bash functions, defined in the abra.sh of the recipe itself.
|
||||
They can be run within the context of a service (e.g. app) or locally on your
|
||||
work station by passing "--local/-l".
|
||||
|
||||
N.B. If using the "--" style to pass arguments, flags (e.g. "--local/-l") must
|
||||
be passed *before* the "--". It is possible to pass arguments without the "--"
|
||||
as long as no dashes are present (i.e. "foo" works without "--", "-foo"
|
||||
does not).`,
|
||||
Example: ` # pass <cmd> args/flags without "--"
|
||||
abra app cmd 1312.net app my_cmd_arg foo --user bar
|
||||
|
||||
# pass <cmd> args/flags with "--"
|
||||
abra app cmd 1312.net app my_cmd_args --user bar -- foo -vvv
|
||||
|
||||
# drop the [service] arg if using "--local/-l"
|
||||
abra app cmd 1312.net my_cmd --local`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if local {
|
||||
if !(len(args) >= 2) {
|
||||
return errors.New("requires at least 2 arguments with --local/-l")
|
||||
}
|
||||
|
||||
if slices.Contains(os.Args, "--") {
|
||||
if cmd.ArgsLenAtDash() > 2 {
|
||||
return errors.New("accepts at most 2 args with --local/-l")
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE(d1): it is unclear how to correctly validate this case
|
||||
//
|
||||
// abra app cmd 1312.net app test_cmd_args foo --local
|
||||
// FATAL <recipe> doesn't have a app function
|
||||
//
|
||||
// "app" should not be there, but there is no reliable way to detect arg
|
||||
// count when the user can pass an arbitrary amount of recipe command
|
||||
// arguments
|
||||
return nil
|
||||
}
|
||||
|
||||
if !(len(args) >= 3) {
|
||||
return errors.New("requires at least 3 arguments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
if !local {
|
||||
return autocomplete.ServiceNameComplete(args[0])
|
||||
}
|
||||
return autocomplete.CommandNameComplete(args[0])
|
||||
case 2:
|
||||
if !local {
|
||||
return autocomplete.CommandNameComplete(args[0])
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if local && remoteUser != "" {
|
||||
log.Fatal("cannot use --local & --user together")
|
||||
}
|
||||
|
||||
hasCmdArgs, parsedCmdArgs := parseCmdArgs(args, local)
|
||||
|
||||
if _, err := os.Stat(app.Recipe.AbraShPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Fatalf("%s does not exist for %s?", app.Recipe.AbraShPath, app.Name)
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if local {
|
||||
cmdName := args[1]
|
||||
if err := internal.EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("--local detected, running %s on local work station", cmdName)
|
||||
|
||||
var exportEnv string
|
||||
for k, v := range app.Env {
|
||||
exportEnv = exportEnv + fmt.Sprintf("%s='%s'; ", k, v)
|
||||
}
|
||||
|
||||
var sourceAndExec string
|
||||
if hasCmdArgs {
|
||||
log.Debugf("parsed following command arguments: %s", parsedCmdArgs)
|
||||
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s %s", app.Name, app.StackName(), exportEnv, app.Recipe.AbraShPath, cmdName, parsedCmdArgs)
|
||||
} else {
|
||||
log.Debug("did not detect any command arguments")
|
||||
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s", app.Name, app.StackName(), exportEnv, app.Recipe.AbraShPath, cmdName)
|
||||
}
|
||||
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); errors.Is(err, os.ErrNotExist) {
|
||||
log.Debugf("%s does not exist locally, use /bin/sh as fallback", shell)
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
cmd := exec.Command(shell, "-c", sourceAndExec)
|
||||
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cmdName := args[2]
|
||||
if err := internal.EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
matchingServiceName := false
|
||||
targetServiceName := args[1]
|
||||
for _, serviceName := range serviceNames {
|
||||
if serviceName == targetServiceName {
|
||||
matchingServiceName = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matchingServiceName {
|
||||
log.Fatalf("no service %s for %s?", targetServiceName, app.Name)
|
||||
}
|
||||
|
||||
log.Debugf("running command %s within the context of %s_%s", cmdName, app.StackName(), targetServiceName)
|
||||
|
||||
if hasCmdArgs {
|
||||
log.Debugf("parsed following command arguments: %s", parsedCmdArgs)
|
||||
} else {
|
||||
log.Debug("did not detect any command arguments")
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.RunCmdRemote(
|
||||
cl,
|
||||
app,
|
||||
disableTTY,
|
||||
app.Recipe.AbraShPath,
|
||||
targetServiceName, cmdName, parsedCmdArgs, remoteUser); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var AppCmdListCommand = &cobra.Command{
|
||||
Use: "list <domain> [flags]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List all available commands",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cmdNames, err := appPkg.ReadAbraShCmdNames(app.Recipe.AbraShPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sort.Strings(cmdNames)
|
||||
|
||||
for _, cmdName := range cmdNames {
|
||||
fmt.Println(cmdName)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func parseCmdArgs(args []string, isLocal bool) (bool, string) {
|
||||
var (
|
||||
parsedCmdArgs string
|
||||
hasCmdArgs bool
|
||||
)
|
||||
|
||||
if isLocal {
|
||||
if len(args) > 2 {
|
||||
return true, fmt.Sprintf("%s ", strings.Join(args[2:], " "))
|
||||
}
|
||||
} else {
|
||||
if len(args) > 3 {
|
||||
return true, fmt.Sprintf("%s ", strings.Join(args[3:], " "))
|
||||
}
|
||||
}
|
||||
|
||||
return hasCmdArgs, parsedCmdArgs
|
||||
}
|
||||
|
||||
var (
|
||||
local bool
|
||||
remoteUser string
|
||||
disableTTY bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppCmdCommand.Flags().BoolVarP(
|
||||
&local,
|
||||
"local",
|
||||
"l",
|
||||
false,
|
||||
"run command locally",
|
||||
)
|
||||
|
||||
AppCmdCommand.Flags().StringVarP(
|
||||
&remoteUser,
|
||||
"user",
|
||||
"u",
|
||||
"",
|
||||
"request remote user",
|
||||
)
|
||||
|
||||
AppCmdCommand.Flags().BoolVarP(
|
||||
&disableTTY,
|
||||
"tty",
|
||||
"T",
|
||||
false,
|
||||
"disable remote TTY",
|
||||
)
|
||||
|
||||
AppCmdCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
31
cli/app/cmd_test.go
Normal file
31
cli/app/cmd_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCmdArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []string
|
||||
shouldParse bool
|
||||
expectedOutput string
|
||||
}{
|
||||
// `--` is not parsed when passed in from the command-line e.g. -- foo bar baz
|
||||
// so we need to eumlate that as missing when testing if bash args are passed in
|
||||
// see https://git.coopcloud.tech/toolshed/organising/issues/336 for more
|
||||
{[]string{"foo.com", "app", "test"}, false, ""},
|
||||
{[]string{"foo.com", "app", "test", "foo"}, true, "foo "},
|
||||
{[]string{"foo.com", "app", "test", "foo", "bar", "baz"}, true, "foo bar baz "},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
ok, parsed := parseCmdArgs(test.input, false)
|
||||
if ok != test.shouldParse {
|
||||
t.Fatalf("[%s] should not parse", strings.Join(test.input, " "))
|
||||
}
|
||||
if parsed != test.expectedOutput {
|
||||
t.Fatalf("%s does not match %s", parsed, test.expectedOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,59 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appConfigCommand = &cli.Command{
|
||||
Name: "config",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "Edit app config",
|
||||
Action: func(c *cli.Context) error {
|
||||
appName := c.Args().First()
|
||||
|
||||
if appName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
||||
}
|
||||
|
||||
files, err := config.LoadAppFiles("")
|
||||
var AppConfigCommand = &cobra.Command{
|
||||
Use: "config <domain> [flags]",
|
||||
Aliases: []string{"cfg"},
|
||||
Short: "Edit app config",
|
||||
Example: " abra config 1312.net",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
files, err := appPkg.LoadAppFiles("")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
appName := args[0]
|
||||
appFile, exists := files[appName]
|
||||
if !exists {
|
||||
logrus.Fatalf("cannot find app with name %s", appName)
|
||||
log.Fatalf("cannot find app with name %s", appName)
|
||||
}
|
||||
|
||||
ed, ok := os.LookupEnv("EDITOR")
|
||||
if !ok {
|
||||
edPrompt := &survey.Select{
|
||||
Message: "Which editor do you wish to use?",
|
||||
Message: "which editor do you wish to use?",
|
||||
Options: []string{"vi", "vim", "nvim", "nano", "pico", "emacs"},
|
||||
}
|
||||
if err := survey.AskOne(edPrompt, &ed); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(ed, appFile.Path)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
c := exec.Command(ed, appFile.Path)
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
468
cli/app/cp.go
468
cli/app/cp.go
@ -1,155 +1,383 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/container"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appCpCommand = &cli.Command{
|
||||
Name: "cp",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<src> <dst>",
|
||||
Usage: "Copy files to/from a running app service",
|
||||
Description: `
|
||||
This command supports copying files to and from any app service file system.
|
||||
var AppCpCommand = &cobra.Command{
|
||||
Use: "cp <domain> <src> <dst> [flags]",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Copy files to/from a deployed app service",
|
||||
Example: ` # copy myfile.txt to the root of the app service
|
||||
abra app cp 1312.net myfile.txt app:/
|
||||
|
||||
If you want to copy a myfile.txt to the root of the app service:
|
||||
|
||||
abra app cp <app> myfile.txt app:/
|
||||
|
||||
And if you want to copy that file back to your current working directory locally:
|
||||
|
||||
abra app cp <app> app:/myfile.txt .
|
||||
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
src := c.Args().Get(1)
|
||||
dst := c.Args().Get(2)
|
||||
if src == "" {
|
||||
logrus.Fatal("missing <src> argument")
|
||||
} else if dst == "" {
|
||||
logrus.Fatal("missing <dest> argument")
|
||||
# copy that file back to your current working directory locally
|
||||
abra app cp 1312.net app:/myfile.txt ./`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
src := args[1]
|
||||
dst := args[2]
|
||||
srcPath, dstPath, service, toContainer, err := parseSrcAndDst(src, dst)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
container, err := containerPkg.GetContainerFromStackAndService(cl, app.StackName(), service)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
|
||||
|
||||
if toContainer {
|
||||
err = CopyToContainer(cl, container.ID, srcPath, dstPath)
|
||||
} else {
|
||||
err = CopyFromContainer(cl, container.ID, srcPath, dstPath)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
parsedSrc := strings.SplitN(src, ":", 2)
|
||||
parsedDst := strings.SplitN(dst, ":", 2)
|
||||
errorMsg := "one of <src>/<dest> arguments must take $SERVICE:$PATH form"
|
||||
if len(parsedSrc) == 2 && len(parsedDst) == 2 {
|
||||
logrus.Fatal(errorMsg)
|
||||
} else if len(parsedSrc) != 2 {
|
||||
if len(parsedDst) != 2 {
|
||||
logrus.Fatal(errorMsg)
|
||||
}
|
||||
} else if len(parsedDst) != 2 {
|
||||
if len(parsedSrc) != 2 {
|
||||
logrus.Fatal(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
var service string
|
||||
var srcPath string
|
||||
var dstPath string
|
||||
isToContainer := false // <container:src> <dst>
|
||||
if len(parsedSrc) == 2 {
|
||||
service = parsedSrc[0]
|
||||
srcPath = parsedSrc[1]
|
||||
dstPath = dst
|
||||
logrus.Debugf("assuming transfer is coming FROM the container")
|
||||
} else if len(parsedDst) == 2 {
|
||||
service = parsedDst[0]
|
||||
dstPath = parsedDst[1]
|
||||
srcPath = src
|
||||
isToContainer = true // <src> <container:dst>
|
||||
logrus.Debugf("assuming transfer is going TO the container")
|
||||
}
|
||||
|
||||
if !isToContainer {
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist locally?", dstPath)
|
||||
}
|
||||
}
|
||||
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
func configureAndCp(
|
||||
c *cli.Context,
|
||||
app config.App,
|
||||
srcPath string,
|
||||
dstPath string,
|
||||
service string,
|
||||
isToContainer bool) error {
|
||||
appFiles, err := config.LoadAppFiles("")
|
||||
var errServiceMissing = errors.New("one of <src>/<dest> arguments must take $SERVICE:$PATH form")
|
||||
|
||||
// parseSrcAndDst parses src and dest string. One of src or dst must be of the form $SERVICE:$PATH
|
||||
func parseSrcAndDst(src, dst string) (srcPath string, dstPath string, service string, toContainer bool, err error) {
|
||||
parsedSrc := strings.SplitN(src, ":", 2)
|
||||
parsedDst := strings.SplitN(dst, ":", 2)
|
||||
if len(parsedSrc)+len(parsedDst) != 3 {
|
||||
return "", "", "", false, errServiceMissing
|
||||
}
|
||||
if len(parsedSrc) == 2 {
|
||||
return parsedSrc[1], dst, parsedSrc[0], false, nil
|
||||
}
|
||||
if len(parsedDst) == 2 {
|
||||
return src, parsedDst[1], parsedDst[0], true, nil
|
||||
}
|
||||
return "", "", "", false, errServiceMissing
|
||||
}
|
||||
|
||||
// CopyToContainer copies a file or directory from the local file system to the container.
|
||||
// See the possible copy modes and their documentation.
|
||||
func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
|
||||
srcStat, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return fmt.Errorf("local %s ", err)
|
||||
}
|
||||
|
||||
appEnv, err := config.GetApp(appFiles, app.Name)
|
||||
dstStat, err := cl.ContainerStatPath(context.Background(), containerID, dstPath)
|
||||
dstExists := true
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
|
||||
|
||||
container, err := container.GetContainer(c.Context, cl, filters, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
|
||||
|
||||
if isToContainer {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
logrus.Fatalf("%s does not exist?", srcPath)
|
||||
if errdefs.IsNotFound(err) {
|
||||
dstExists = false
|
||||
} else {
|
||||
return fmt.Errorf("remote path: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
||||
content, err := archive.TarWithOptions(srcPath, toTarOpts)
|
||||
mode, err := copyMode(srcPath, dstPath, srcStat.Mode(), dstStat.Mode, dstExists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
movePath := ""
|
||||
switch mode {
|
||||
case CopyModeDirToDir:
|
||||
// Add the src directory to the destination path
|
||||
_, srcDir := path.Split(srcPath)
|
||||
dstPath = path.Join(dstPath, srcDir)
|
||||
|
||||
// Make sure the dst directory exits.
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
if _, err := container.RunExec(dcli, cl, containerID, &containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"mkdir", "-p", dstPath},
|
||||
Detach: false,
|
||||
Tty: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create remote directory: %s", err)
|
||||
}
|
||||
case CopyModeFileToFile:
|
||||
// Remove the file component from the path, since docker can only copy
|
||||
// to a directory.
|
||||
dstPath, _ = path.Split(dstPath)
|
||||
case CopyModeFileToFileRename:
|
||||
// Copy the file to the temp directory and move it to its dstPath
|
||||
// afterwards.
|
||||
movePath = dstPath
|
||||
dstPath = "/tmp"
|
||||
}
|
||||
|
||||
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
||||
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
|
||||
logrus.Fatal(err)
|
||||
toTarOpts := &archive.TarOptions{IncludeSourceDir: true, NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
||||
content, err := archive.TarWithOptions(srcPath, toTarOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("copy %s from local to %s on container", srcPath, dstPath)
|
||||
copyOpts := containertypes.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
||||
if err := cl.CopyToContainer(context.Background(), containerID, dstPath, content, copyOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if movePath != "" {
|
||||
_, srcFile := path.Split(srcPath)
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := container.RunExec(dcli, cl, containerID, &containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"mv", path.Join("/tmp", srcFile), movePath},
|
||||
Detach: false,
|
||||
Tty: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create remote directory: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyFromContainer copies a file or directory from the given container to the local file system.
|
||||
// See the possible copy modes and their documentation.
|
||||
func CopyFromContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
|
||||
srcStat, err := cl.ContainerStatPath(context.Background(), containerID, srcPath)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("remote: %s does not exist", srcPath)
|
||||
} else {
|
||||
return fmt.Errorf("remote path: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
dstStat, err := os.Stat(dstPath)
|
||||
dstExists := true
|
||||
var dstMode os.FileMode
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
dstExists = false
|
||||
} else {
|
||||
return fmt.Errorf("remote path: %s", err)
|
||||
}
|
||||
} else {
|
||||
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
dstMode = dstStat.Mode()
|
||||
}
|
||||
|
||||
mode, err := copyMode(srcPath, dstPath, srcStat.Mode, dstMode, dstExists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
moveDstDir := ""
|
||||
moveDstFile := ""
|
||||
switch mode {
|
||||
case CopyModeFileToFile:
|
||||
// Remove the file component from the path, since docker can only copy
|
||||
// to a directory.
|
||||
dstPath, _ = path.Split(dstPath)
|
||||
case CopyModeFileToFileRename:
|
||||
// Copy the file to the temp directory and move it to its dstPath
|
||||
// afterwards.
|
||||
moveDstFile = dstPath
|
||||
dstPath = "/tmp"
|
||||
case CopyModeFilesToDir:
|
||||
// Copy the directory to the temp directory and move it to its
|
||||
// dstPath afterwards.
|
||||
moveDstDir = path.Join(dstPath, "/")
|
||||
dstPath = "/tmp"
|
||||
|
||||
// Make sure the temp directory always gets removed
|
||||
defer os.Remove(path.Join("/tmp"))
|
||||
}
|
||||
|
||||
content, _, err := cl.CopyFromContainer(context.Background(), containerID, srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy: %s", err)
|
||||
}
|
||||
defer content.Close()
|
||||
if err := archive.Untar(content, dstPath, &archive.TarOptions{
|
||||
NoOverwriteDirNonDir: true,
|
||||
Compression: archive.Gzip,
|
||||
NoLchown: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("untar: %s", err)
|
||||
}
|
||||
|
||||
if moveDstFile != "" {
|
||||
_, srcFile := path.Split(strings.TrimSuffix(srcPath, "/"))
|
||||
if err := moveFile(path.Join("/tmp", srcFile), moveDstFile); err != nil {
|
||||
return err
|
||||
}
|
||||
defer content.Close()
|
||||
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
||||
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if moveDstDir != "" {
|
||||
_, srcDir := path.Split(strings.TrimSuffix(srcPath, "/"))
|
||||
if err := moveDir(path.Join("/tmp", srcDir), moveDstDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrCopyDirToFile = fmt.Errorf("can't copy dir to file")
|
||||
ErrDstDirNotExist = fmt.Errorf("destination directory does not exist")
|
||||
)
|
||||
|
||||
type CopyMode int
|
||||
|
||||
const (
|
||||
// Copy a src file to a dest file. The src and dest file names are the same.
|
||||
// <dir_src>/<file> + <dir_dst>/<file> -> <dir_dst>/<file>
|
||||
CopyModeFileToFile = CopyMode(iota)
|
||||
// Copy a src file to a dest file. The src and dest file names are not the same.
|
||||
// <dir_src>/<file_src> + <dir_dst>/<file_dst> -> <dir_dst>/<file_dst>
|
||||
CopyModeFileToFileRename
|
||||
// Copy a src file to dest directory. The dest file gets created in the dest
|
||||
// folder with the src filename.
|
||||
// <dir_src>/<file> + <dir_dst> -> <dir_dst>/<file>
|
||||
CopyModeFileToDir
|
||||
// Copy a src directory to dest directory.
|
||||
// <dir_src> + <dir_dst> -> <dir_dst>/<dir_src>
|
||||
CopyModeDirToDir
|
||||
// Copy all files in the src directory to the dest directory. This works recursively.
|
||||
// <dir_src>/ + <dir_dst> -> <dir_dst>/<files_from_dir_src>
|
||||
CopyModeFilesToDir
|
||||
)
|
||||
|
||||
// copyMode takes a src and dest path and file mode to determine the copy mode.
|
||||
// See the possible copy modes and their documentation.
|
||||
func copyMode(srcPath, dstPath string, srcMode os.FileMode, dstMode os.FileMode, dstExists bool) (CopyMode, error) {
|
||||
_, srcFile := path.Split(srcPath)
|
||||
_, dstFile := path.Split(dstPath)
|
||||
if srcMode.IsDir() {
|
||||
if !dstExists {
|
||||
return -1, ErrDstDirNotExist
|
||||
}
|
||||
if dstMode.IsDir() {
|
||||
if strings.HasSuffix(srcPath, "/") {
|
||||
return CopyModeFilesToDir, nil
|
||||
}
|
||||
return CopyModeDirToDir, nil
|
||||
}
|
||||
return -1, ErrCopyDirToFile
|
||||
}
|
||||
|
||||
if dstMode.IsDir() {
|
||||
return CopyModeFileToDir, nil
|
||||
}
|
||||
|
||||
if srcFile != dstFile {
|
||||
return CopyModeFileToFileRename, nil
|
||||
}
|
||||
|
||||
return CopyModeFileToFile, nil
|
||||
}
|
||||
|
||||
// moveDir moves all files from a source path to the destination path recursively.
|
||||
func moveDir(sourcePath, destPath string) error {
|
||||
return filepath.Walk(sourcePath, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath := path.Join(destPath, strings.TrimPrefix(p, sourcePath))
|
||||
if info.IsDir() {
|
||||
err := os.Mkdir(newPath, info.Mode())
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
return moveFile(p, newPath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// moveFile moves a file from a source path to a destination path.
|
||||
func moveFile(sourcePath, destPath string) error {
|
||||
inputFile, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
inputFile.Close()
|
||||
return err
|
||||
}
|
||||
defer outputFile.Close()
|
||||
_, err = io.Copy(outputFile, inputFile)
|
||||
inputFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove file after succesfull copy.
|
||||
err = os.Remove(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppCpCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
113
cli/app/cp_test.go
Normal file
113
cli/app/cp_test.go
Normal file
@ -0,0 +1,113 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
src string
|
||||
dst string
|
||||
srcPath string
|
||||
dstPath string
|
||||
service string
|
||||
toContainer bool
|
||||
err error
|
||||
}{
|
||||
{src: "foo", dst: "bar", err: errServiceMissing},
|
||||
{src: "app:foo", dst: "app:bar", err: errServiceMissing},
|
||||
{src: "app:foo", dst: "bar", srcPath: "foo", dstPath: "bar", service: "app", toContainer: false},
|
||||
{src: "foo", dst: "app:bar", srcPath: "foo", dstPath: "bar", service: "app", toContainer: true},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
srcPath, dstPath, service, toContainer, err := parseSrcAndDst(tc.src, tc.dst)
|
||||
if srcPath != tc.srcPath {
|
||||
t.Errorf("[%d] srcPath: want (%s), got(%s)", i, tc.srcPath, srcPath)
|
||||
}
|
||||
if dstPath != tc.dstPath {
|
||||
t.Errorf("[%d] dstPath: want (%s), got(%s)", i, tc.dstPath, dstPath)
|
||||
}
|
||||
if service != tc.service {
|
||||
t.Errorf("[%d] service: want (%s), got(%s)", i, tc.service, service)
|
||||
}
|
||||
if toContainer != tc.toContainer {
|
||||
t.Errorf("[%d] toConainer: want (%t), got(%t)", i, tc.toContainer, toContainer)
|
||||
}
|
||||
if err == nil && tc.err != nil && err.Error() != tc.err.Error() {
|
||||
t.Errorf("[%d] err: want (%s), got(%s)", i, tc.err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
srcPath string
|
||||
dstPath string
|
||||
srcMode os.FileMode
|
||||
dstMode os.FileMode
|
||||
dstExists bool
|
||||
mode CopyMode
|
||||
err error
|
||||
}{
|
||||
{
|
||||
srcPath: "foo.txt",
|
||||
dstPath: "foo.txt",
|
||||
srcMode: os.ModePerm,
|
||||
dstMode: os.ModePerm,
|
||||
dstExists: true,
|
||||
mode: CopyModeFileToFile,
|
||||
},
|
||||
{
|
||||
srcPath: "foo.txt",
|
||||
dstPath: "bar.txt",
|
||||
srcMode: os.ModePerm,
|
||||
dstExists: true,
|
||||
mode: CopyModeFileToFileRename,
|
||||
},
|
||||
{
|
||||
srcPath: "foo",
|
||||
dstPath: "foo",
|
||||
srcMode: os.ModeDir,
|
||||
dstMode: os.ModeDir,
|
||||
dstExists: true,
|
||||
mode: CopyModeDirToDir,
|
||||
},
|
||||
{
|
||||
srcPath: "foo/",
|
||||
dstPath: "foo",
|
||||
srcMode: os.ModeDir,
|
||||
dstMode: os.ModeDir,
|
||||
dstExists: true,
|
||||
mode: CopyModeFilesToDir,
|
||||
},
|
||||
{
|
||||
srcPath: "foo",
|
||||
dstPath: "foo",
|
||||
srcMode: os.ModeDir,
|
||||
dstExists: false,
|
||||
mode: -1,
|
||||
err: ErrDstDirNotExist,
|
||||
},
|
||||
{
|
||||
srcPath: "foo",
|
||||
dstPath: "foo",
|
||||
srcMode: os.ModeDir,
|
||||
dstMode: os.ModePerm,
|
||||
dstExists: true,
|
||||
mode: -1,
|
||||
err: ErrCopyDirToFile,
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
mode, err := copyMode(tc.srcPath, tc.dstPath, tc.srcMode, tc.dstMode, tc.dstExists)
|
||||
if mode != tc.mode {
|
||||
t.Errorf("[%d] mode: want (%d), got(%d)", i, tc.mode, mode)
|
||||
}
|
||||
if err != tc.err {
|
||||
t.Errorf("[%d] err: want (%s), got(%s)", i, tc.err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,354 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appDeployCommand = &cli.Command{
|
||||
Name: "deploy",
|
||||
var AppDeployCommand = &cobra.Command{
|
||||
Use: "deploy <domain> [version] [flags]",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Deploy an app",
|
||||
Flags: []cli.Flag{
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.NoDomainChecksFlag,
|
||||
internal.DontWaitConvergeFlag,
|
||||
Short: "Deploy an app",
|
||||
Long: `Deploy an app.
|
||||
|
||||
This command supports chaos operations. Use "--chaos/-C" to deploy your recipe
|
||||
checkout as-is. Recipe commit hashes are also supported as values for
|
||||
"[version]". Please note, "upgrade"/"rollback" do not support chaos operations.`,
|
||||
Example: ` # standard deployment
|
||||
abra app deploy 1312.net
|
||||
|
||||
# chaos deployment
|
||||
abra app deploy 1312.net --chaos
|
||||
|
||||
# deploy specific version
|
||||
abra app deploy 1312.net 2.0.0+1.2.3
|
||||
|
||||
# deploy a specific git hash
|
||||
abra app deploy 1312.net 886db76d`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.RecipeVersionComplete(app.Recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Description: `
|
||||
This command deploys an app. It does not support incrementing the version of a
|
||||
deployed app, for this you need to look at the "abra app upgrade <app>"
|
||||
command.
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var (
|
||||
deployWarnMessages []string
|
||||
toDeployVersion string
|
||||
)
|
||||
|
||||
You may pass "--force" to re-deploy the same version again. This can be useful
|
||||
if the container runtime has gotten into a weird state.
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
recipes.
|
||||
`,
|
||||
Action: internal.DeployAction,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
if err := validateArgsAndFlags(args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("checking whether %s is already deployed", app.StackName())
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if deployMeta.IsDeployed && !(internal.Force || internal.Chaos) {
|
||||
log.Fatalf("%s is already deployed", app.Name)
|
||||
}
|
||||
|
||||
toDeployVersion, err = getDeployVersion(args, deployMeta, app)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("get deploy version: %s", err))
|
||||
}
|
||||
|
||||
if !internal.Chaos {
|
||||
_, err = app.Recipe.EnsureVersion(toDeployVersion)
|
||||
if err != nil {
|
||||
log.Fatalf("ensure recipe: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(app.Recipe); err != nil {
|
||||
if internal.Chaos {
|
||||
log.Warn(err)
|
||||
} else {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateSecrets(cl, app); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for k, v := range abraShEnv {
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
appPkg.ExposeAllEnv(stackName, compose, app.Env)
|
||||
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
|
||||
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
|
||||
if internal.Chaos {
|
||||
appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion)
|
||||
}
|
||||
appPkg.SetUpdateLabel(compose, stackName, app.Env)
|
||||
appPkg.SetVersionLabel(compose, stackName, toDeployVersion)
|
||||
|
||||
envVars, err := appPkg.CheckEnv(app)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
if !envVar.Present {
|
||||
deployWarnMessages = append(deployWarnMessages,
|
||||
fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !internal.NoDomainChecks {
|
||||
if domainName, ok := app.Env["DOMAIN"]; ok {
|
||||
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Debug("skipping domain checks, no DOMAIN=... configured")
|
||||
}
|
||||
} else {
|
||||
log.Debug("skipping domain checks")
|
||||
}
|
||||
|
||||
deployedVersion := config.NO_VERSION_DEFAULT
|
||||
if deployMeta.IsDeployed {
|
||||
deployedVersion = deployMeta.Version
|
||||
}
|
||||
|
||||
if err := internal.DeployOverview(
|
||||
app,
|
||||
deployedVersion,
|
||||
toDeployVersion,
|
||||
"",
|
||||
deployWarnMessages,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(
|
||||
cl,
|
||||
deployOpts,
|
||||
compose,
|
||||
app.Name,
|
||||
app.Server,
|
||||
internal.DontWaitConverge,
|
||||
f,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
postDeployCmds, ok := app.Env["POST_DEPLOY_CMDS"]
|
||||
if ok && !internal.DontWaitConverge {
|
||||
log.Debugf("run the following post-deploy commands: %s", postDeployCmds)
|
||||
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
|
||||
log.Fatalf("attempting to run post deploy commands, saw: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.WriteRecipeVersion(toDeployVersion, false); err != nil {
|
||||
log.Fatalf("writing recipe version failed: %s", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func getLatestVersionOrCommit(app app.App) (string, error) {
|
||||
versions, err := app.Recipe.Tags()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(versions) > 0 && !internal.Chaos {
|
||||
return versions[len(versions)-1], nil
|
||||
}
|
||||
|
||||
head, err := app.Recipe.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return formatter.SmallSHA(head.String()), nil
|
||||
}
|
||||
|
||||
// validateArgsAndFlags ensures compatible args/flags.
|
||||
func validateArgsAndFlags(args []string) error {
|
||||
if len(args) == 2 && args[1] != "" && internal.Chaos {
|
||||
return fmt.Errorf("cannot use [version] and --chaos together")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecrets(cl *dockerClient.Client, app app.App) error {
|
||||
secStats, err := secret.PollSecretsStatus(cl, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, secStat := range secStats {
|
||||
if !secStat.CreatedOnRemote {
|
||||
return fmt.Errorf("secret not generated: %s", secStat.LocalName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDeployVersion(cliArgs []string, deployMeta stack.DeployMeta, app app.App) (string, error) {
|
||||
// Chaos mode overrides everything
|
||||
if internal.Chaos {
|
||||
v, err := app.Recipe.ChaosVersion()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Debugf("version: taking chaos version: %s", v)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Check if the deploy version is set with a cli argument
|
||||
if len(cliArgs) == 2 && cliArgs[1] != "" {
|
||||
log.Debugf("version: taking version from cli arg: %s", cliArgs[1])
|
||||
return cliArgs[1], nil
|
||||
}
|
||||
|
||||
// Check if the recipe has a version in the .env file
|
||||
if app.Recipe.EnvVersion != "" && !internal.IgnoreEnvVersion {
|
||||
if strings.HasSuffix(app.Recipe.EnvVersionRaw, "+U") {
|
||||
return "", fmt.Errorf("version: can not redeploy chaos version %s", app.Recipe.EnvVersionRaw)
|
||||
}
|
||||
log.Debugf("version: taking version from .env file: %s", app.Recipe.EnvVersion)
|
||||
return app.Recipe.EnvVersion, nil
|
||||
}
|
||||
|
||||
// Take deployed version
|
||||
if deployMeta.IsDeployed {
|
||||
log.Debugf("version: taking deployed version: %s", deployMeta.Version)
|
||||
return deployMeta.Version, nil
|
||||
}
|
||||
|
||||
v, err := getLatestVersionOrCommit(app)
|
||||
log.Debugf("version: taking new recipe version: %s", v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppDeployCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppDeployCommand.Flags().BoolVarP(
|
||||
&internal.Force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"perform action without further prompt",
|
||||
)
|
||||
|
||||
AppDeployCommand.Flags().BoolVarP(
|
||||
&internal.NoDomainChecks,
|
||||
"no-domain-checks",
|
||||
"D",
|
||||
false,
|
||||
"disable public DNS checks",
|
||||
)
|
||||
|
||||
AppDeployCommand.Flags().BoolVarP(
|
||||
&internal.DontWaitConverge,
|
||||
"no-converge-checks",
|
||||
"c",
|
||||
false,
|
||||
"disable converge logic checks",
|
||||
)
|
||||
}
|
||||
|
||||
43
cli/app/env.go
Normal file
43
cli/app/env.go
Normal file
@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AppEnvCommand = &cobra.Command{
|
||||
Use: "env <domain> [flags]",
|
||||
Aliases: []string{"e"},
|
||||
Short: "Show app .env values",
|
||||
Example: " abra app env 1312.net",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
var envKeys []string
|
||||
for k := range app.Env {
|
||||
envKeys = append(envKeys, k)
|
||||
}
|
||||
|
||||
sort.Strings(envKeys)
|
||||
|
||||
var rows [][]string
|
||||
for _, k := range envKeys {
|
||||
rows = append(rows, []string{k, app.Env[k]})
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview("ENV OVERVIEW", rows)
|
||||
fmt.Println(overview)
|
||||
},
|
||||
}
|
||||
@ -1,136 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var appErrorsCommand = &cli.Command{
|
||||
Name: "errors",
|
||||
Usage: "List errors for a deployed app",
|
||||
Description: `
|
||||
This command lists errors for a deployed app.
|
||||
|
||||
This is a best-effort implementation and an attempt to gather a number of tips
|
||||
& tricks for finding errors together into one convenient command. When an app
|
||||
is failing to deploy or having issues, it could be a lot of things.
|
||||
|
||||
This command currently takes into account:
|
||||
|
||||
Is the service deployed?
|
||||
Is the service killed by an OOM error?
|
||||
Is the service reporting an error (like in "ps --no-trunc" output)
|
||||
Is the service healthcheck failing? what are the healthcheck logs?
|
||||
|
||||
Got any more ideas? Please let us know:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
|
||||
|
||||
This command is best accompanied by "abra app logs <app>" which may reveal
|
||||
further information which can help you debug the cause of an app failure via
|
||||
the logs.
|
||||
|
||||
`,
|
||||
Aliases: []string{"e"},
|
||||
Flags: []cli.Flag{internal.WatchFlag},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if !internal.Watch {
|
||||
if err := checkErrors(c, cl, app); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
if err := checkErrors(c, cl, app); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
|
||||
recipe, err := recipe.Get(app.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", service.Name)
|
||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
logrus.Warnf("%s is not up, something seems wrong", service.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
container := containers[0]
|
||||
containerState, err := cl.ContainerInspect(c.Context, container.ID)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if containerState.State.OOMKilled {
|
||||
logrus.Warnf("%s has been killed due to an out of memory error", service.Name)
|
||||
}
|
||||
|
||||
if containerState.State.Error != "" {
|
||||
logrus.Warnf("%s reports this error: %s", service.Name, containerState.State.Error)
|
||||
}
|
||||
|
||||
if containerState.State.Health != nil {
|
||||
if containerState.State.Health.Status != "healthy" {
|
||||
logrus.Warnf("%s healthcheck status is %s", service.Name, containerState.State.Health.Status)
|
||||
logrus.Warnf("%s healthcheck has failed %s times", service.Name, strconv.Itoa(containerState.State.Health.FailingStreak))
|
||||
for _, log := range containerState.State.Health.Log {
|
||||
logrus.Warnf("%s healthcheck logs: %s", service.Name, strings.TrimSpace(log.Output))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getServiceName(names []string) string {
|
||||
containerName := strings.Join(names, " ")
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
return strings.Split(trimmed, ".")[0]
|
||||
}
|
||||
139
cli/app/labels.go
Normal file
139
cli/app/labels.go
Normal file
@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AppLabelsCommand = &cobra.Command{
|
||||
Use: "labels <domain> [flags]",
|
||||
Aliases: []string{"lb"},
|
||||
Short: "Show deployment labels",
|
||||
Long: "Both local recipe and live deployment labels are shown.",
|
||||
Example: " abra app labels 1312.net",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
remoteLabels, err := getLabels(cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
rows := [][]string{
|
||||
{"DEPLOYED LABELS", "---"},
|
||||
}
|
||||
|
||||
remoteLabelKeys := make([]string, 0, len(remoteLabels))
|
||||
for k := range remoteLabels {
|
||||
remoteLabelKeys = append(remoteLabelKeys, k)
|
||||
}
|
||||
|
||||
sort.Strings(remoteLabelKeys)
|
||||
|
||||
for _, k := range remoteLabelKeys {
|
||||
rows = append(rows, []string{
|
||||
k,
|
||||
remoteLabels[k],
|
||||
})
|
||||
}
|
||||
|
||||
if len(remoteLabelKeys) == 0 {
|
||||
rows = append(rows, []string{"unknown"})
|
||||
}
|
||||
|
||||
rows = append(rows, []string{"RECIPE LABELS", "---"})
|
||||
|
||||
config, err := app.Recipe.GetComposeConfig(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var localLabelKeys []string
|
||||
var appServiceConfig composetypes.ServiceConfig
|
||||
for _, service := range config.Services {
|
||||
if service.Name == "app" {
|
||||
appServiceConfig = service
|
||||
|
||||
for k := range service.Deploy.Labels {
|
||||
localLabelKeys = append(localLabelKeys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(localLabelKeys)
|
||||
|
||||
for _, k := range localLabelKeys {
|
||||
rows = append(rows, []string{
|
||||
k,
|
||||
appServiceConfig.Deploy.Labels[k],
|
||||
})
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview("LABELS OVERVIEW", rows)
|
||||
fmt.Println(overview)
|
||||
},
|
||||
}
|
||||
|
||||
// getLabels reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}".
|
||||
func getLabels(cl *dockerClient.Client, stackName string) (map[string]string, error) {
|
||||
labels := make(map[string]string)
|
||||
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
|
||||
|
||||
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return labels, err
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
if service.Spec.Name != fmt.Sprintf("%s_app", stackName) {
|
||||
continue
|
||||
}
|
||||
|
||||
for k, v := range service.Spec.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppLabelsCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
335
cli/app/list.go
335
cli/app/list.go
@ -1,118 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var status bool
|
||||
var statusFlag = &cli.BoolFlag{
|
||||
Name: "status",
|
||||
Aliases: []string{"S"},
|
||||
Value: false,
|
||||
Usage: "Show app deployment status",
|
||||
Destination: &status,
|
||||
}
|
||||
|
||||
var appType string
|
||||
var typeFlag = &cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific type",
|
||||
Destination: &appType,
|
||||
}
|
||||
|
||||
var listAppServer string
|
||||
var listAppServerFlag = &cli.StringFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific server",
|
||||
Destination: &listAppServer,
|
||||
}
|
||||
|
||||
type appStatus struct {
|
||||
server string
|
||||
recipe string
|
||||
appName string
|
||||
domain string
|
||||
status string
|
||||
version string
|
||||
upgrade string
|
||||
Server string `json:"server"`
|
||||
Recipe string `json:"recipe"`
|
||||
AppName string `json:"appName"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
Chaos string `json:"chaos"`
|
||||
ChaosVersion string `json:"chaosVersion"`
|
||||
AutoUpdate string `json:"autoUpdate"`
|
||||
Version string `json:"version"`
|
||||
Upgrade string `json:"upgrade"`
|
||||
}
|
||||
|
||||
type serverStatus struct {
|
||||
apps []appStatus
|
||||
appCount int
|
||||
versionCount int
|
||||
unversionedCount int
|
||||
latestCount int
|
||||
upgradeCount int
|
||||
Apps []appStatus `json:"apps"`
|
||||
AppCount int `json:"appCount"`
|
||||
VersionCount int `json:"versionCount"`
|
||||
UnversionedCount int `json:"unversionedCount"`
|
||||
LatestCount int `json:"latestCount"`
|
||||
UpgradeCount int `json:"upgradeCount"`
|
||||
}
|
||||
|
||||
var appListCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all managed apps",
|
||||
Description: `
|
||||
This command looks at your local file system listing of apps and servers (e.g.
|
||||
in ~/.abra/) to generate a report of all your apps.
|
||||
|
||||
By passing the "--status/-S" flag, you can query all your servers for the
|
||||
actual live deployment status. Depending on how many servers you manage, this
|
||||
can take some time.
|
||||
`,
|
||||
var AppListCommand = &cobra.Command{
|
||||
Use: "list [flags]",
|
||||
Aliases: []string{"ls"},
|
||||
Flags: []cli.Flag{
|
||||
statusFlag,
|
||||
listAppServerFlag,
|
||||
typeFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
appFiles, err := config.LoadAppFiles(listAppServer)
|
||||
Short: "List all managed apps",
|
||||
Long: `Generate a report of all managed apps.
|
||||
|
||||
Use "--status/-S" flag to query all servers for the live deployment status.`,
|
||||
Example: ` # list apps of all servers without live status
|
||||
abra app ls
|
||||
|
||||
# list apps of a specific server with live status
|
||||
abra app ls -s 1312.net -S
|
||||
|
||||
# list apps of all servers which match a specific recipe
|
||||
abra app ls -r gitea`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
appFiles, err := appPkg.LoadAppFiles(listAppServer)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
apps, err := config.GetApps(appFiles)
|
||||
apps, err := appPkg.GetApps(appFiles, recipeFilter)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sort.Sort(config.ByServerAndType(apps))
|
||||
sort.Sort(appPkg.ByServerAndRecipe(apps))
|
||||
|
||||
statuses := make(map[string]map[string]string)
|
||||
var catl recipe.RecipeCatalogue
|
||||
if status {
|
||||
alreadySeen := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
if _, ok := alreadySeen[app.Server]; !ok {
|
||||
if err := ssh.EnsureHostKey(app.Server); err != nil {
|
||||
logrus.Fatal(fmt.Sprintf(internal.SSHFailMsg, app.Server))
|
||||
}
|
||||
alreadySeen[app.Server] = true
|
||||
}
|
||||
}
|
||||
|
||||
statuses, err = config.GetAppStatuses(appFiles)
|
||||
statuses, err = appPkg.GetAppStatuses(apps, internal.MachineReadable)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
catl, err = recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,56 +90,77 @@ can take some time.
|
||||
var ok bool
|
||||
if stats, ok = allStats[app.Server]; !ok {
|
||||
stats = serverStatus{}
|
||||
if appType == "" {
|
||||
if recipeFilter == "" {
|
||||
// count server, no filtering
|
||||
totalServersCount++
|
||||
}
|
||||
}
|
||||
|
||||
if app.Type == appType || appType == "" {
|
||||
if appType != "" {
|
||||
if app.Recipe.Name == recipeFilter || recipeFilter == "" {
|
||||
if recipeFilter != "" {
|
||||
// only count server if matches filter
|
||||
totalServersCount++
|
||||
}
|
||||
|
||||
appStats := appStatus{}
|
||||
stats.appCount++
|
||||
stats.AppCount++
|
||||
totalAppsCount++
|
||||
|
||||
if status {
|
||||
status := "unknown"
|
||||
version := "unknown"
|
||||
chaos := "unknown"
|
||||
chaosVersion := "unknown"
|
||||
autoUpdate := "unknown"
|
||||
if statusMeta, ok := statuses[app.StackName()]; ok {
|
||||
if currentVersion, exists := statusMeta["version"]; exists {
|
||||
version = currentVersion
|
||||
if currentVersion != "" {
|
||||
version = currentVersion
|
||||
}
|
||||
}
|
||||
if chaosDeploy, exists := statusMeta["chaos"]; exists {
|
||||
chaos = chaosDeploy
|
||||
}
|
||||
if chaosDeployVersion, exists := statusMeta["chaosVersion"]; exists {
|
||||
chaosVersion = chaosDeployVersion
|
||||
}
|
||||
if autoUpdateState, exists := statusMeta["autoUpdate"]; exists {
|
||||
autoUpdate = autoUpdateState
|
||||
}
|
||||
if statusMeta["status"] != "" {
|
||||
status = statusMeta["status"]
|
||||
}
|
||||
stats.versionCount++
|
||||
stats.VersionCount++
|
||||
} else {
|
||||
stats.unversionedCount++
|
||||
stats.UnversionedCount++
|
||||
}
|
||||
|
||||
appStats.status = status
|
||||
appStats.version = version
|
||||
appStats.Status = status
|
||||
appStats.Chaos = chaos
|
||||
appStats.ChaosVersion = chaosVersion
|
||||
appStats.Version = version
|
||||
appStats.AutoUpdate = autoUpdate
|
||||
|
||||
var newUpdates []string
|
||||
if version != "unknown" {
|
||||
updates, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
|
||||
if version != "unknown" && chaos == "false" {
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Fatalf("unable to clone %s: %s", app.Name, err)
|
||||
}
|
||||
|
||||
updates, err := app.Recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatalf("unable to retrieve tags for %s: %s", app.Name, err)
|
||||
}
|
||||
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
parsedUpdate, err := tagcmp.Parse(update)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
||||
@ -184,27 +171,37 @@ can take some time.
|
||||
|
||||
if len(newUpdates) == 0 {
|
||||
if version == "unknown" {
|
||||
appStats.upgrade = "unknown"
|
||||
appStats.Upgrade = "unknown"
|
||||
} else {
|
||||
appStats.upgrade = "latest"
|
||||
stats.latestCount++
|
||||
appStats.Upgrade = "latest"
|
||||
stats.LatestCount++
|
||||
}
|
||||
} else {
|
||||
newUpdates = internal.ReverseStringList(newUpdates)
|
||||
appStats.upgrade = strings.Join(newUpdates, "\n")
|
||||
stats.upgradeCount++
|
||||
newUpdates = internal.SortVersionsDesc(newUpdates)
|
||||
appStats.Upgrade = strings.Join(newUpdates, "\n")
|
||||
stats.UpgradeCount++
|
||||
}
|
||||
}
|
||||
|
||||
appStats.server = app.Server
|
||||
appStats.recipe = app.Type
|
||||
appStats.appName = app.Name
|
||||
appStats.domain = app.Domain
|
||||
appStats.Server = app.Server
|
||||
appStats.Recipe = app.Recipe.Name
|
||||
appStats.AppName = app.Name
|
||||
appStats.Domain = app.Domain
|
||||
|
||||
stats.apps = append(stats.apps, appStats)
|
||||
stats.Apps = append(stats.Apps, appStats)
|
||||
}
|
||||
allStats[app.Server] = stats
|
||||
}
|
||||
|
||||
if internal.MachineReadable {
|
||||
jsonstring, err := json.Marshal(allStats)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
} else {
|
||||
fmt.Println(string(jsonstring))
|
||||
}
|
||||
|
||||
allStats[app.Server] = stats
|
||||
return
|
||||
}
|
||||
|
||||
alreadySeen := make(map[string]bool)
|
||||
@ -215,50 +212,118 @@ can take some time.
|
||||
|
||||
serverStat := allStats[app.Server]
|
||||
|
||||
tableCol := []string{"recipe", "domain", "app name"}
|
||||
headers := []string{"RECIPE", "DOMAIN", "SERVER"}
|
||||
if status {
|
||||
tableCol = append(tableCol, []string{"status", "version", "upgrade"}...)
|
||||
headers = append(headers, []string{
|
||||
"STATUS",
|
||||
"CHAOS",
|
||||
"VERSION",
|
||||
"UPGRADE",
|
||||
"AUTOUPDATE"}...,
|
||||
)
|
||||
}
|
||||
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, appStat := range serverStat.apps {
|
||||
tableRow := []string{appStat.recipe, appStat.domain, appStat.appName}
|
||||
table.Headers(headers...)
|
||||
|
||||
var rows [][]string
|
||||
for _, appStat := range serverStat.Apps {
|
||||
row := []string{appStat.Recipe, appStat.Domain, appStat.Server}
|
||||
if status {
|
||||
tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...)
|
||||
chaosStatus := appStat.Chaos
|
||||
if chaosStatus != "unknown" {
|
||||
chaosEnabled, err := strconv.ParseBool(chaosStatus)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if chaosEnabled && appStat.ChaosVersion != "unknown" {
|
||||
chaosStatus = appStat.ChaosVersion
|
||||
}
|
||||
}
|
||||
|
||||
row = append(row, []string{
|
||||
appStat.Status,
|
||||
chaosStatus,
|
||||
appStat.Version,
|
||||
appStat.Upgrade,
|
||||
appStat.AutoUpdate}...,
|
||||
)
|
||||
}
|
||||
table.Append(tableRow)
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
table.Rows(rows...)
|
||||
|
||||
if status {
|
||||
fmt.Println(fmt.Sprintf(
|
||||
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
|
||||
app.Server,
|
||||
serverStat.appCount,
|
||||
serverStat.versionCount,
|
||||
serverStat.unversionedCount,
|
||||
serverStat.latestCount,
|
||||
serverStat.upgradeCount,
|
||||
))
|
||||
} else {
|
||||
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount))
|
||||
if len(rows) > 0 {
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allStats) > 1 && table.NumLines() > 0 {
|
||||
fmt.Println() // newline separator for multiple servers
|
||||
if len(allStats) > 1 && len(rows) > 0 {
|
||||
fmt.Println() // newline separator for multiple servers
|
||||
}
|
||||
}
|
||||
|
||||
alreadySeen[app.Server] = true
|
||||
}
|
||||
|
||||
if len(allStats) > 1 {
|
||||
fmt.Println(fmt.Sprintf("total servers: %v | total apps: %v ", totalServersCount, totalAppsCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
status bool
|
||||
recipeFilter string
|
||||
listAppServer string
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppListCommand.Flags().BoolVarP(
|
||||
&status,
|
||||
"status",
|
||||
"S",
|
||||
false,
|
||||
"show app deployment status",
|
||||
)
|
||||
|
||||
AppListCommand.Flags().StringVarP(
|
||||
&recipeFilter,
|
||||
"recipe",
|
||||
"r",
|
||||
"",
|
||||
"show apps of a specific recipe",
|
||||
)
|
||||
|
||||
AppListCommand.RegisterFlagCompletionFunc(
|
||||
"recipe",
|
||||
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
)
|
||||
|
||||
AppListCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
|
||||
AppListCommand.Flags().StringVarP(
|
||||
&listAppServer,
|
||||
"server",
|
||||
"s",
|
||||
"",
|
||||
"show apps of a specific server",
|
||||
)
|
||||
|
||||
AppListCommand.RegisterFlagCompletionFunc(
|
||||
"server",
|
||||
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.ServerNameComplete()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
177
cli/app/logs.go
177
cli/app/logs.go
@ -1,122 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/logs"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var logOpts = types.ContainerLogsOptions{
|
||||
Details: false,
|
||||
Follow: true,
|
||||
ShowStderr: true,
|
||||
ShowStdout: true,
|
||||
Tail: "20",
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
// stackLogs lists logs for all stack services
|
||||
func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", stackName)
|
||||
serviceOpts := types.ServiceListOptions{Filters: filters}
|
||||
services, err := client.ServiceList(c.Context, serviceOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, service := range services {
|
||||
wg.Add(1)
|
||||
go func(s string) {
|
||||
if internal.StdErrOnly {
|
||||
logOpts.ShowStdout = false
|
||||
}
|
||||
|
||||
logs, err := client.ServiceLogs(c.Context, s, logOpts)
|
||||
var AppLogsCommand = &cobra.Command{
|
||||
Use: "logs <domain> [service] [flags]",
|
||||
Aliases: []string{"l"},
|
||||
Short: "Tail app logs",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
defer logs.Close()
|
||||
|
||||
_, err = io.Copy(os.Stdout, logs)
|
||||
if err != nil && err != io.EOF {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}(service.ID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
var appLogsCommand = &cli.Command{
|
||||
Name: "logs",
|
||||
Aliases: []string{"l"},
|
||||
ArgsUsage: "[<service>]",
|
||||
Usage: "Tail app logs",
|
||||
Flags: []cli.Flag{
|
||||
internal.StdErrOnlyFlag,
|
||||
return autocomplete.ServiceNameComplete(app.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
serviceName := c.Args().Get(1)
|
||||
if serviceName == "" {
|
||||
logrus.Debugf("tailing logs for all %s services", app.Type)
|
||||
stackLogs(c, app.StackName(), cl)
|
||||
} else {
|
||||
logrus.Debugf("tailing logs for %s", serviceName)
|
||||
if err := tailServiceLogs(c, cl, app, serviceName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
var serviceNames []string
|
||||
if len(args) == 2 {
|
||||
serviceNames = []string{args[1]}
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
opts := logs.TailOpts{
|
||||
AppName: app.Name,
|
||||
Services: serviceNames,
|
||||
StdErr: stdErr,
|
||||
Since: sinceLogs,
|
||||
Filters: f,
|
||||
}
|
||||
|
||||
if err := logs.TailLogs(cl, opts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName))
|
||||
chosenService, err := service.GetService(c.Context, cl, filters, internal.NoInput)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
var (
|
||||
stdErr bool
|
||||
sinceLogs string
|
||||
)
|
||||
|
||||
if internal.StdErrOnly {
|
||||
logOpts.ShowStdout = false
|
||||
}
|
||||
func init() {
|
||||
AppLogsCommand.Flags().BoolVarP(
|
||||
&stdErr,
|
||||
"stderr",
|
||||
"s",
|
||||
false,
|
||||
"only tail stderr",
|
||||
)
|
||||
|
||||
logs, err := cl.ServiceLogs(c.Context, chosenService.ID, logOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
defer logs.Close()
|
||||
|
||||
_, err = io.Copy(os.Stdout, logs)
|
||||
if err != nil && err != io.EOF {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
AppLogsCommand.Flags().StringVarP(
|
||||
&sinceLogs,
|
||||
"since",
|
||||
"S",
|
||||
"",
|
||||
"tail logs since YYYY-MM-DDTHH:MM:SSZ",
|
||||
)
|
||||
}
|
||||
|
||||
313
cli/app/move.go
Normal file
313
cli/app/move.go
Normal file
@ -0,0 +1,313 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/docker/docker/api/types"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AppMoveCommand = &cobra.Command{
|
||||
Use: "move <domain> <server> [flags]",
|
||||
Short: "Moves an app to a different server",
|
||||
Long: `Move an app to a differnt server.
|
||||
|
||||
This will copy secrets and volumes from the old server to the new one. It will also undeploy the app from old server but not deploy it on the new. You will have to do that your self, after the move finished.
|
||||
|
||||
Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
|
||||
Example: ` # moving an app
|
||||
abra app move nextcloud.example.com myserver.com`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
return autocomplete.ServerNameComplete()
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
if len(args) <= 1 {
|
||||
log.Fatal("no server provided")
|
||||
}
|
||||
newServer := args[1]
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
resources, err := getAppResources(cl, app)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
internal.MoveOverview(app, newServer, resources.SecretNames(), resources.VolumeNames())
|
||||
if err := internal.PromptProcced(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: wait timeout will be removed, until it actually is just set it to a high value.
|
||||
stack.WaitTimeout = 500
|
||||
rmOpts := stack.Remove{
|
||||
Namespaces: []string{app.StackName()},
|
||||
Detach: false,
|
||||
}
|
||||
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl2, err := client.New(newServer)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, s := range resources.SecretList {
|
||||
sname := strings.Split(strings.TrimPrefix(s.Spec.Name, app.StackName()+"_"), "_")
|
||||
secretName := strings.Join(sname[:len(sname)-1], "_")
|
||||
data := resources.Secrets[secretName]
|
||||
if err := client.StoreSecret(cl2, s.Spec.Name, data); err != nil {
|
||||
log.Infof("creating secret: %s", s.Spec.Name)
|
||||
log.Errorf("failed to store secret on new server: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range resources.Volumes {
|
||||
log.Infof("moving volume: %s", v.Name)
|
||||
|
||||
// Need to create the volume before copying the data, because when
|
||||
// docker creates a new volume it set the folder permissions to
|
||||
// root, which might be wrong. This ensures we always have the
|
||||
// correct folder permissions inside the volume.
|
||||
log.Debug("creating volume: %s", v.Name)
|
||||
_, err := cl2.VolumeCreate(context.Background(), volume.CreateOptions{
|
||||
Name: v.Name,
|
||||
Driver: v.Driver,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to create volume: %s", err)
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s.tar.gz", v.Name)
|
||||
log.Debug("creating %s", fileName)
|
||||
cmd := exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo tar --same-owner -czhpf %s -C /var/lib/docker/volumes %s", fileName, v.Name))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to tar volume: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
log.Debug("copying %s to local machine", fileName)
|
||||
cmd = exec.Command("scp", fmt.Sprintf("%s:%s", app.Server, fileName), fileName)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to copy tar to local machine: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
log.Debug("copying %s to %s", fileName, newServer)
|
||||
cmd = exec.Command("scp", fileName, fmt.Sprintf("%s:%s", newServer, fileName))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to copy tar to new server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
log.Debug("extracting %s on %s", fileName, newServer)
|
||||
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo tar --same-owner -xzpf %s -C /var/lib/docker/volumes", fileName))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to extract tar: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
// Remove tar files
|
||||
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo rm %s", fileName))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar from new server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
cmd = exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo rm %s", fileName))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar from old server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
cmd = exec.Command("rm", fileName)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar on local machine: %s", err)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("moving app config to new server")
|
||||
if err := copyFile(app.Path, strings.ReplaceAll(app.Path, app.Server, newServer)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(app.Path); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("% was succefully moved to %s", app.Name, newServer)
|
||||
fmt.Println("Run the following command to deploy the app", app.Name, newServer)
|
||||
fmt.Println(" abra app deploy --no-domain-checks", app.Domain)
|
||||
fmt.Println()
|
||||
fmt.Println("And don't forget to update you DNS record. And don't panic, as it might take a bit for the dust to settle. Traefik for example might fail to obtain the lets encrypt certificate for a while.", app.Domain)
|
||||
fmt.Println()
|
||||
fmt.Println("If anything goes wrong, you can always move the app config file to the original server and deploy it there again. There was no data removed on the old server")
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
type AppResources struct {
|
||||
Secrets map[string]string
|
||||
SecretList []swarm.Secret
|
||||
Volumes map[string]containertypes.MountPoint
|
||||
}
|
||||
|
||||
func (a *AppResources) SecretNames() []string {
|
||||
secrets := []string{}
|
||||
for name := range a.Secrets {
|
||||
secrets = append(secrets, name)
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
func (a *AppResources) VolumeNames() []string {
|
||||
volumes := []string{}
|
||||
for name := range a.Volumes {
|
||||
volumes = append(volumes, name)
|
||||
}
|
||||
return volumes
|
||||
}
|
||||
|
||||
func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error) {
|
||||
filter, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretConfigs, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
resources := &AppResources{
|
||||
Secrets: make(map[string]string),
|
||||
SecretList: secretList,
|
||||
Volumes: make(map[string]containertypes.MountPoint),
|
||||
}
|
||||
for _, s := range services {
|
||||
secretNames := map[string]string{}
|
||||
for _, serviceCompose := range compose.Services {
|
||||
if app.StackName()+"_"+serviceCompose.Name != s.Spec.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, secret := range serviceCompose.Secrets {
|
||||
for _, s := range secretList {
|
||||
if s.Spec.Name == app.StackName()+"_"+secret.Source+"_"+secretConfigs[secret.Source].Version {
|
||||
secretNames[secret.Source] = s.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f := filters.NewArgs()
|
||||
f.Add("name", s.Spec.Name)
|
||||
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, f, true)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
continue
|
||||
}
|
||||
for _, m := range targetContainer.Mounts {
|
||||
if m.Type == mount.TypeVolume {
|
||||
resources.Volumes[m.Name] = m
|
||||
}
|
||||
}
|
||||
|
||||
for secretName, secretID := range secretNames {
|
||||
if _, ok := resources.Secrets[secretName]; ok {
|
||||
continue
|
||||
}
|
||||
log.Debugf("extracting secret %s", secretName)
|
||||
|
||||
out, err := exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo cat /var/lib/docker/containers/%s/mounts/secrets/%s", targetContainer.ID, secretID)).Output()
|
||||
if err != nil {
|
||||
fmt.Println(string(out))
|
||||
fmt.Println(err)
|
||||
continue
|
||||
}
|
||||
resources.Secrets[secretName] = string(out)
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func copyFile(src string, dst string) error {
|
||||
// Read all content of src to data, may cause OOM for a large file.
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Write data to dst
|
||||
err = os.WriteFile(dst, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppMoveCommand.Flags().BoolVarP(
|
||||
&internal.Dry,
|
||||
"dry-run",
|
||||
"r",
|
||||
false,
|
||||
"report changes that would be made",
|
||||
)
|
||||
}
|
||||
383
cli/app/new.go
383
cli/app/new.go
@ -1,21 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appNewDescription = `
|
||||
This command takes a recipe and uses it to create a new app. This new app
|
||||
configuration is stored in your ~/.abra directory under the appropriate server.
|
||||
var appNewDescription = `Creates a new app from a default recipe.
|
||||
|
||||
This new app configuration is stored in your $ABRA_DIR directory under the
|
||||
appropriate server.
|
||||
|
||||
This command does not deploy your app for you. You will need to run "abra app
|
||||
deploy <app>" to do so.
|
||||
deploy <domain>" to do so.
|
||||
|
||||
You can see what recipes are available (i.e. values for the <recipe> argument)
|
||||
You can see what recipes are available (i.e. values for the [recipe] argument)
|
||||
by running "abra recipe ls".
|
||||
|
||||
Recipe commit hashes are supported values for "[version]".
|
||||
|
||||
Passing the "--secrets/-S" flag will automatically generate secrets for your
|
||||
app and store them encrypted at rest on the chosen target server. These
|
||||
generated secrets are only visible at generation time, so please take care to
|
||||
@ -23,22 +39,345 @@ store them somewhere safe.
|
||||
|
||||
You can use the "--pass/-P" to store these generated passwords locally in a
|
||||
pass store (see passwordstore.org for more). The pass command must be available
|
||||
on your $PATH.
|
||||
`
|
||||
on your $PATH.`
|
||||
|
||||
var appNewCommand = &cli.Command{
|
||||
Name: "new",
|
||||
Usage: "Create a new app",
|
||||
Aliases: []string{"n"},
|
||||
Description: appNewDescription,
|
||||
Flags: []cli.Flag{
|
||||
internal.NewAppServerFlag,
|
||||
internal.DomainFlag,
|
||||
internal.NewAppNameFlag,
|
||||
internal.PassFlag,
|
||||
internal.SecretsFlag,
|
||||
var AppNewCommand = &cobra.Command{
|
||||
Use: "new [recipe] [version] [flags]",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new app",
|
||||
Long: appNewDescription,
|
||||
Args: cobra.RangeArgs(0, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.RecipeNameComplete()
|
||||
case 1:
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
return autocomplete.RecipeVersionComplete(recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
if len(args) == 2 && internal.Chaos {
|
||||
log.Fatal("cannot use [version] and --chaos together")
|
||||
}
|
||||
|
||||
var recipeVersion string
|
||||
if len(args) == 2 {
|
||||
recipeVersion = args[1]
|
||||
}
|
||||
|
||||
chaosVersion := config.CHAOS_DEFAULT
|
||||
if internal.Chaos {
|
||||
var err error
|
||||
chaosVersion, err = recipe.ChaosVersion()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipeVersion = chaosVersion
|
||||
} else {
|
||||
if err := recipe.EnsureIsClean(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var recipeVersions recipePkg.RecipeVersions
|
||||
if recipeVersion == "" {
|
||||
var err error
|
||||
recipeVersions, _, err = recipe.GetRecipeVersions()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(recipeVersions) > 0 {
|
||||
latest := recipeVersions[len(recipeVersions)-1]
|
||||
for tag := range latest {
|
||||
recipeVersion = tag
|
||||
}
|
||||
|
||||
if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := recipe.EnsureLatest(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if recipeVersion == "" {
|
||||
head, err := recipe.Head()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to retrieve latest commit for %s: %s", recipe.Name, err)
|
||||
}
|
||||
|
||||
recipeVersion = formatter.SmallSHA(head.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureServerFlag(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDomainFlag(recipe, newAppServer); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sanitisedAppName := appPkg.SanitiseAppName(appDomain)
|
||||
log.Debugf("%s sanitised as %s for new app", appDomain, sanitisedAppName)
|
||||
|
||||
if err := appPkg.TemplateAppEnvSample(
|
||||
recipe,
|
||||
appDomain,
|
||||
newAppServer,
|
||||
appDomain,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var appSecrets AppSecrets
|
||||
var secretsTable *table.Table
|
||||
if generateSecrets {
|
||||
sampleEnv, err := recipe.SampleEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
composeFiles, err := recipe.GetComposeFiles(sampleEnv)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretsConfig, err := secret.ReadSecretsConfig(
|
||||
recipe.SampleEnvPath,
|
||||
composeFiles,
|
||||
appPkg.StackName(appDomain),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promptForSecrets(recipe.Name, secretsConfig); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(newAppServer)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
appSecrets, err = createSecrets(cl, secretsConfig, sanitisedAppName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretsTable, err = formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{"NAME", "VALUE"}
|
||||
secretsTable.Headers(headers...)
|
||||
|
||||
for name, val := range appSecrets {
|
||||
secretsTable.Row(name, val)
|
||||
}
|
||||
}
|
||||
|
||||
if newAppServer == "default" {
|
||||
newAppServer = "local"
|
||||
}
|
||||
|
||||
log.Infof("%s created (version: %s)", appDomain, recipeVersion)
|
||||
|
||||
if len(appSecrets) > 0 {
|
||||
rows := [][]string{}
|
||||
for k, v := range appSecrets {
|
||||
rows = append(rows, []string{k, v})
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview("SECRETS OVERVIEW", rows)
|
||||
|
||||
fmt.Println(overview)
|
||||
|
||||
log.Warnf(
|
||||
"secrets are %s shown again, please save them %s",
|
||||
formatter.BoldUnderlineStyle.Render("NOT"),
|
||||
formatter.BoldUnderlineStyle.Render("NOW"),
|
||||
)
|
||||
}
|
||||
|
||||
app, err := app.Get(appDomain)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.WriteRecipeVersion(recipeVersion, false); err != nil {
|
||||
log.Fatalf("writing recipe version failed: %s", err)
|
||||
}
|
||||
},
|
||||
ArgsUsage: "<recipe>",
|
||||
Action: internal.NewAction,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
||||
// AppSecrets represents all app secrest
|
||||
type AppSecrets map[string]string
|
||||
|
||||
// createSecrets creates all secrets for a new app.
|
||||
func createSecrets(cl *dockerClient.Client, secretsConfig map[string]secret.Secret, sanitisedAppName string) (AppSecrets, error) {
|
||||
// NOTE(d1): trim to match app.StackName() implementation
|
||||
if len(sanitisedAppName) > config.MAX_SANITISED_APP_NAME_LENGTH {
|
||||
log.Debugf("trimming %s to %s to avoid runtime limits", sanitisedAppName, sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH])
|
||||
sanitisedAppName = sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH]
|
||||
}
|
||||
|
||||
secrets, err := secret.GenerateSecrets(cl, secretsConfig, newAppServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if saveInPass {
|
||||
for secretName := range secrets {
|
||||
secretValue := secrets[secretName]
|
||||
if err := secret.PassInsertSecret(
|
||||
secretValue,
|
||||
secretName,
|
||||
appDomain,
|
||||
newAppServer,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
|
||||
func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
|
||||
if appDomain == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify app domain",
|
||||
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &appDomain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if appDomain == "" {
|
||||
return fmt.Errorf("no domain provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptForSecrets asks if we should generate secrets for a new app.
|
||||
func promptForSecrets(recipeName string, secretsConfig map[string]secret.Secret) error {
|
||||
if len(secretsConfig) == 0 {
|
||||
log.Debugf("%s has no secrets to generate, skipping...", recipeName)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !generateSecrets && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "Generate app secrets?",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &generateSecrets); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
|
||||
func ensureServerFlag() error {
|
||||
servers, err := config.GetServers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(servers) == 1 {
|
||||
newAppServer = servers[0]
|
||||
log.Infof("single server detected, choosing %s automatically", newAppServer)
|
||||
return nil
|
||||
}
|
||||
|
||||
if newAppServer == "" && !internal.NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select app server:",
|
||||
Options: servers,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &newAppServer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if newAppServer == "" {
|
||||
return fmt.Errorf("no server provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
newAppServer string
|
||||
appDomain string
|
||||
saveInPass bool
|
||||
generateSecrets bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppNewCommand.Flags().StringVarP(
|
||||
&newAppServer,
|
||||
"server",
|
||||
"s",
|
||||
"",
|
||||
"specify server for new app",
|
||||
)
|
||||
|
||||
AppNewCommand.RegisterFlagCompletionFunc(
|
||||
"server",
|
||||
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.ServerNameComplete()
|
||||
},
|
||||
)
|
||||
|
||||
AppNewCommand.Flags().StringVarP(
|
||||
&appDomain,
|
||||
"domain",
|
||||
"D",
|
||||
"",
|
||||
"domain name for app",
|
||||
)
|
||||
|
||||
AppNewCommand.Flags().BoolVarP(
|
||||
&saveInPass,
|
||||
"pass",
|
||||
"p",
|
||||
false,
|
||||
"store secrets in a local pass store",
|
||||
)
|
||||
|
||||
AppNewCommand.Flags().BoolVarP(
|
||||
&generateSecrets,
|
||||
"secrets",
|
||||
"S",
|
||||
false,
|
||||
"automatically generate secrets",
|
||||
)
|
||||
|
||||
AppNewCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
228
cli/app/ps.go
228
cli/app/ps.go
@ -1,96 +1,210 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
abraService "coopcloud.tech/abra/pkg/service"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/buger/goterm"
|
||||
dockerFormatter "github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/docker/docker/api/types"
|
||||
containerTypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appPsCommand = &cli.Command{
|
||||
Name: "ps",
|
||||
Usage: "Check app status",
|
||||
Description: "This command shows a more detailed status output of a specific deployed app.",
|
||||
Aliases: []string{"p"},
|
||||
Flags: []cli.Flag{
|
||||
internal.WatchFlag,
|
||||
var AppPsCommand = &cobra.Command{
|
||||
Use: "ps <domain> [flags]",
|
||||
Aliases: []string{"p"},
|
||||
Short: "Check app deployment status",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if !internal.Watch {
|
||||
showPSOutput(c, app, cl)
|
||||
return nil
|
||||
chaosVersion := config.CHAOS_DEFAULT
|
||||
statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true)
|
||||
if statusMeta, ok := statuses[app.StackName()]; ok {
|
||||
if isChaos, exists := statusMeta["chaos"]; exists && isChaos == "true" {
|
||||
if cVersion, exists := statusMeta["chaosVersion"]; exists {
|
||||
chaosVersion = cVersion
|
||||
if strings.HasSuffix(chaosVersion, config.DIRTY_DEFAULT) {
|
||||
chaosVersion = formatter.BoldDirtyDefault(chaosVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goterm.Clear()
|
||||
for {
|
||||
goterm.MoveCursor(1, 1)
|
||||
showPSOutput(c, app, cl)
|
||||
goterm.Flush()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
showPSOutput(app, cl, deployMeta.Version, chaosVersion)
|
||||
},
|
||||
}
|
||||
|
||||
// showPSOutput renders ps output.
|
||||
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
|
||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
||||
func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chaosVersion string) {
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
tableCol := []string{"service name", "image", "created", "status", "state", "ports"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, container := range containers {
|
||||
var containerNames []string
|
||||
for _, containerName := range container.Names {
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
containerNames = append(containerNames, trimmed)
|
||||
}
|
||||
|
||||
tableRow := []string{
|
||||
service.ContainerToServiceName(container.Names, app.StackName()),
|
||||
formatter.RemoveSha(container.Image),
|
||||
formatter.HumanDuration(container.Created),
|
||||
container.Status,
|
||||
container.State,
|
||||
dockerFormatter.DisplayablePorts(container.Ports),
|
||||
}
|
||||
table.Append(tableRow)
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: app.StackName(),
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
table.Render()
|
||||
services := compose.Services
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].Name < services[j].Name
|
||||
})
|
||||
|
||||
var rows [][]string
|
||||
allContainerStats := make(map[string]map[string]string)
|
||||
for _, service := range services {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
|
||||
|
||||
containers, err := cl.ContainerList(context.Background(), containerTypes.ListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
|
||||
var containerStats map[string]string
|
||||
if len(containers) == 0 {
|
||||
containerStats = map[string]string{
|
||||
"version": deployedVersion,
|
||||
"chaos": chaosVersion,
|
||||
"service": service.Name,
|
||||
"image": "unknown",
|
||||
"created": "unknown",
|
||||
"status": "unknown",
|
||||
"state": "unknown",
|
||||
"ports": "unknown",
|
||||
}
|
||||
} else {
|
||||
container := containers[0]
|
||||
containerStats = map[string]string{
|
||||
"version": deployedVersion,
|
||||
"chaos": chaosVersion,
|
||||
"service": abraService.ContainerToServiceName(container.Names, app.StackName()),
|
||||
"image": formatter.RemoveSha(container.Image),
|
||||
"created": formatter.HumanDuration(container.Created),
|
||||
"status": container.Status,
|
||||
"state": container.State,
|
||||
"ports": dockerFormatter.DisplayablePorts(container.Ports),
|
||||
}
|
||||
}
|
||||
|
||||
allContainerStats[containerStats["service"]] = containerStats
|
||||
|
||||
// NOTE(d1): don't clobber these variables for --machine output
|
||||
dVersion := deployedVersion
|
||||
cVersion := chaosVersion
|
||||
|
||||
if containerStats["service"] != "app" {
|
||||
// NOTE(d1): don't repeat info which only relevant for the "app" service
|
||||
dVersion = ""
|
||||
cVersion = ""
|
||||
}
|
||||
|
||||
row := []string{
|
||||
containerStats["service"],
|
||||
containerStats["status"],
|
||||
containerStats["image"],
|
||||
dVersion,
|
||||
cVersion,
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
if internal.MachineReadable {
|
||||
rendered, err := json.Marshal(allContainerStats)
|
||||
if err != nil {
|
||||
log.Fatal("unable to convert to JSON: %s", err)
|
||||
}
|
||||
|
||||
fmt.Println(string(rendered))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"SERVICE",
|
||||
"STATUS",
|
||||
"IMAGE",
|
||||
"VERSION",
|
||||
"CHAOS",
|
||||
}
|
||||
|
||||
table.
|
||||
Headers(headers...).
|
||||
Rows(rows...)
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppPsCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
|
||||
AppPsCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,74 +1,102 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Volumes stores the variable from VolumesFlag
|
||||
var Volumes bool
|
||||
|
||||
// VolumesFlag is used to specify if volumes should be deleted when deleting an app
|
||||
var VolumesFlag = &cli.BoolFlag{
|
||||
Name: "volumes",
|
||||
Value: false,
|
||||
Destination: &Volumes,
|
||||
}
|
||||
|
||||
var appRemoveCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove an already undeployed app",
|
||||
var AppRemoveCommand = &cobra.Command{
|
||||
Use: "remove <domain> [flags]",
|
||||
Aliases: []string{"rm"},
|
||||
Flags: []cli.Flag{
|
||||
VolumesFlag,
|
||||
internal.ForceFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Short: "Remove all app data, locally and remotely",
|
||||
Long: `Remove everything related to an app which is already undeployed.
|
||||
|
||||
By default, it will prompt for confirmation before proceeding. All secrets,
|
||||
volumes and the local app env file will be deleted.
|
||||
|
||||
Only run this command when you are sure you want to completely remove the app
|
||||
and all associated app data. This is a destructive action, Be Careful!
|
||||
|
||||
If you would like to delete specific volumes or secrets, please use removal
|
||||
sub-commands under "app volume" and "app secret" instead.
|
||||
|
||||
Please note, if you delete the local app env file without removing volumes and
|
||||
secrets first, Abra will *not* be able to help you remove them afterwards.
|
||||
|
||||
To delete everything without prompt, use the "--force/-f" or the "--no-input/n"
|
||||
flag.`,
|
||||
Example: " abra app remove 1312.net",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if !internal.Force && !internal.NoInput {
|
||||
log.Warnf("ALERTA ALERTA: deleting %s data and config (local/remote)", app.Name)
|
||||
|
||||
if !internal.Force {
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("about to delete %s, are you sure?", app.Name),
|
||||
}
|
||||
prompt := &survey.Confirm{Message: "are you sure?"}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("user aborted app removal")
|
||||
log.Fatal("aborting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !internal.Force {
|
||||
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if isDeployed {
|
||||
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
fs := filters.NewArgs()
|
||||
fs.Add("name", app.Name)
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: fs})
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
if deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
|
||||
}
|
||||
|
||||
fs, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
configs, err := client.GetConfigs(cl, context.Background(), app.Server, fs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
configNames := client.GetConfigNames(configs)
|
||||
|
||||
if len(configNames) > 0 {
|
||||
if err := client.RemoveConfigs(cl, context.Background(), configNames, internal.Force); err != nil {
|
||||
log.Fatalf("removing configs failed: %s", err)
|
||||
}
|
||||
|
||||
log.Infof("%d config(s) removed successfully", len(configNames))
|
||||
} else {
|
||||
log.Info("no configs to remove")
|
||||
}
|
||||
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: fs})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secrets := make(map[string]string)
|
||||
@ -80,78 +108,53 @@ var appRemoveCommand = &cli.Command{
|
||||
}
|
||||
|
||||
if len(secrets) > 0 {
|
||||
var secretNamesToRemove []string
|
||||
if !internal.Force {
|
||||
secretsPrompt := &survey.MultiSelect{
|
||||
Message: "which secrets do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: secretNames,
|
||||
Default: secretNames,
|
||||
}
|
||||
if err := survey.AskOne(secretsPrompt, &secretNamesToRemove); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range secretNamesToRemove {
|
||||
err := cl.SecretRemove(c.Context, secrets[name])
|
||||
for _, name := range secretNames {
|
||||
err := cl.SecretRemove(context.Background(), secrets[name])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("secret: %s removed", name))
|
||||
log.Info(fmt.Sprintf("secret: %s removed", name))
|
||||
}
|
||||
} else {
|
||||
logrus.Info("no secrets to remove")
|
||||
log.Info("no secrets to remove")
|
||||
}
|
||||
|
||||
volumeListOKBody, err := cl.VolumeList(c.Context, fs)
|
||||
volumeList := volumeListOKBody.Volumes
|
||||
fs, err = app.Filters(false, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var vols []string
|
||||
for _, vol := range volumeList {
|
||||
vols = append(vols, vol.Name)
|
||||
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, fs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
volumeNames := client.GetVolumeNames(volumeList)
|
||||
|
||||
if len(vols) > 0 {
|
||||
if Volumes {
|
||||
var removeVols []string
|
||||
if !internal.Force {
|
||||
volumesPrompt := &survey.MultiSelect{
|
||||
Message: "which volumes do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: vols,
|
||||
Default: vols,
|
||||
}
|
||||
if err := survey.AskOne(volumesPrompt, &removeVols); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, vol := range removeVols {
|
||||
err := cl.VolumeRemove(c.Context, vol, internal.Force) // last argument is for force removing
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("volume %s removed", vol))
|
||||
}
|
||||
} else {
|
||||
logrus.Info("no volumes were removed")
|
||||
if len(volumeNames) > 0 {
|
||||
err := client.RemoveVolumes(cl, context.Background(), volumeNames, internal.Force, 5)
|
||||
if err != nil {
|
||||
log.Fatalf("removing volumes failed: %s", err)
|
||||
}
|
||||
|
||||
log.Infof("%d volume(s) removed successfully", len(volumeNames))
|
||||
} else {
|
||||
logrus.Info("no volumes to remove")
|
||||
log.Info("no volumes to remove")
|
||||
}
|
||||
|
||||
err = os.Remove(app.Path)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err = os.Remove(app.Path); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("file: %s removed", app.Path))
|
||||
|
||||
return nil
|
||||
log.Info(fmt.Sprintf("file: %s removed", app.Path))
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppRemoveCommand.Flags().BoolVarP(
|
||||
&internal.Force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"perform action without further prompt",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,65 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/ui"
|
||||
upstream "coopcloud.tech/abra/pkg/upstream/service"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appRestartCommand = &cli.Command{
|
||||
Name: "restart",
|
||||
Usage: "Restart an app",
|
||||
Aliases: []string{"re"},
|
||||
ArgsUsage: "<service>",
|
||||
Description: `This command restarts a service within a deployed app.`,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
var AppRestartCommand = &cobra.Command{
|
||||
Use: "restart <domain> [[service] | --all-services] [flags]",
|
||||
Aliases: []string{"re"},
|
||||
Short: "Restart an app",
|
||||
Long: `This command restarts services within a deployed app.
|
||||
|
||||
serviceNameShort := c.Args().Get(1)
|
||||
if serviceNameShort == "" {
|
||||
err := errors.New("missing service?")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
Run "abra app ps <domain>" to see a list of service names.
|
||||
|
||||
Pass "--all-services/-a" to restart all services.`,
|
||||
Example: ` # restart a single app service
|
||||
abra app restart 1312.net app
|
||||
|
||||
# restart all app services
|
||||
abra app restart 1312.net -a`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
if !allServices {
|
||||
return autocomplete.ServiceNameComplete(args[0])
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var serviceName string
|
||||
if len(args) == 2 {
|
||||
serviceName = args[1]
|
||||
}
|
||||
|
||||
if serviceName == "" && !allServices {
|
||||
log.Fatal("missing [service]")
|
||||
}
|
||||
|
||||
if serviceName != "" && allServices {
|
||||
log.Fatal("cannot use [service] and --all-services/-a together")
|
||||
}
|
||||
|
||||
var serviceNames []string
|
||||
if allServices {
|
||||
var err error
|
||||
serviceNames, err = appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
serviceNames = append(serviceNames, serviceName)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
|
||||
|
||||
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName)
|
||||
if err := upstream.RunServiceScale(c.Context, cl, serviceName, 0); err != nil {
|
||||
logrus.Fatal(err)
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName)
|
||||
for _, serviceName := range serviceNames {
|
||||
stackServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
||||
|
||||
logrus.Debugf("attempting to scale %s to 1 (restart logic)", serviceName)
|
||||
if err := upstream.RunServiceScale(c.Context, cl, serviceName, 1); err != nil {
|
||||
logrus.Fatal(err)
|
||||
service, _, err := cl.ServiceInspectWithRaw(
|
||||
context.Background(),
|
||||
stackServiceName,
|
||||
types.ServiceInspectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("attempting to scale %s to 0", stackServiceName)
|
||||
|
||||
if err := upstream.RunServiceScale(context.Background(), cl, stackServiceName, 0); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
waitOpts := stack.WaitOpts{
|
||||
Services: []ui.ServiceMeta{{Name: stackServiceName, ID: service.ID}},
|
||||
AppName: app.Name,
|
||||
ServerName: app.Server,
|
||||
Filters: f,
|
||||
NoLog: true,
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
if err := stack.WaitOnServices(cmd.Context(), cl, waitOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("%s has been scaled to 0", stackServiceName)
|
||||
log.Debugf("attempting to scale %s to 1", stackServiceName)
|
||||
|
||||
if err := upstream.RunServiceScale(context.Background(), cl, stackServiceName, 1); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.WaitOnServices(cmd.Context(), cl, waitOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("%s has been scaled to 1", stackServiceName)
|
||||
log.Infof("%s service successfully restarted", serviceName)
|
||||
}
|
||||
|
||||
if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("%s has been scaled to 1 (restart logic)", serviceName)
|
||||
|
||||
logrus.Infof("%s service successfully restarted", serviceNameShort)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var allServices bool
|
||||
|
||||
func init() {
|
||||
AppRestartCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
AppRestartCommand.Flags().BoolVarP(
|
||||
&allServices,
|
||||
"all-services",
|
||||
"a",
|
||||
false,
|
||||
"restart all services",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,79 +1,135 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var restoreAllServices bool
|
||||
var restoreAllServicesFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Value: false,
|
||||
Destination: &restoreAllServices,
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Restore all services",
|
||||
}
|
||||
var AppRestoreCommand = &cobra.Command{
|
||||
Use: "restore <domain> [flags]",
|
||||
Aliases: []string{"rs"},
|
||||
Short: "Restore a snapshot",
|
||||
Long: `Snapshots are restored while apps are deployed.
|
||||
|
||||
var appRestoreCommand = &cli.Command{
|
||||
Name: "restore",
|
||||
Usage: "Restore an app from a backup",
|
||||
Aliases: []string{"rs"},
|
||||
Flags: []cli.Flag{restoreAllServicesFlag},
|
||||
ArgsUsage: "<service> [<backup file>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Some restore scenarios may require service / app restarts.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if c.Args().Len() > 1 && restoreAllServices {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together"))
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
abraSh := path.Join(config.RECIPES_DIR, app.Type, "abra.sh")
|
||||
if _, err := os.Stat(abraSh); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist?", abraSh)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
sourceCmd := fmt.Sprintf("source %s", abraSh)
|
||||
execCmd := "abra_restore"
|
||||
if !restoreAllServices {
|
||||
serviceName := c.Args().Get(1)
|
||||
if serviceName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no service(s) target provided"))
|
||||
}
|
||||
execCmd = fmt.Sprintf("abra_restore_%s", serviceName)
|
||||
}
|
||||
|
||||
bytes, err := ioutil.ReadFile(abraSh)
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(bytes), execCmd) {
|
||||
logrus.Fatalf("%s doesn't have a %s function", app.Type, execCmd)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
backupFile := c.Args().Get(2)
|
||||
if backupFile != "" {
|
||||
execCmd = fmt.Sprintf("%s %s", execCmd, backupFile)
|
||||
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
|
||||
cmd := exec.Command("bash", "-c", sourceAndExec)
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
execEnv := []string{
|
||||
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||
"MACHINE_LOGS=true",
|
||||
}
|
||||
|
||||
return nil
|
||||
if snapshot != "" {
|
||||
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
|
||||
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
|
||||
}
|
||||
|
||||
if targetPath != "" {
|
||||
log.Debugf("including TARGET=%s in backupbot exec invocation", targetPath)
|
||||
execEnv = append(execEnv, fmt.Sprintf("TARGET=%s", targetPath))
|
||||
}
|
||||
|
||||
if internal.NoInput {
|
||||
log.Debugf("including NONINTERACTIVE=%v in backupbot exec invocation", internal.NoInput)
|
||||
execEnv = append(execEnv, fmt.Sprintf("NONINTERACTIVE=%v", internal.NoInput))
|
||||
}
|
||||
|
||||
if len(volumes) > 0 {
|
||||
allVolumes := strings.Join(volumes, ",")
|
||||
log.Debugf("including VOLUMES=%s in backupbot exec invocation", allVolumes)
|
||||
execEnv = append(execEnv, fmt.Sprintf("VOLUMES=%s", allVolumes))
|
||||
}
|
||||
|
||||
if len(services) > 0 {
|
||||
allServices := strings.Join(services, ",")
|
||||
log.Debugf("including CONTAINER=%s in backupbot exec invocation", allServices)
|
||||
execEnv = append(execEnv, fmt.Sprintf("CONTAINER=%s", allServices))
|
||||
}
|
||||
|
||||
if hooks {
|
||||
log.Debugf("including NO_COMMANDS=%v in backupbot exec invocation", false)
|
||||
execEnv = append(execEnv, fmt.Sprintf("NO_COMMANDS=%v", false))
|
||||
}
|
||||
|
||||
if _, err := internal.RunBackupCmdRemote(cl, "restore", targetContainer.ID, execEnv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
targetPath string
|
||||
hooks bool
|
||||
services []string
|
||||
volumes []string
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppRestoreCommand.Flags().StringVarP(
|
||||
&targetPath,
|
||||
"target",
|
||||
"t",
|
||||
"/",
|
||||
"target path",
|
||||
)
|
||||
|
||||
AppRestoreCommand.Flags().StringArrayVarP(
|
||||
&services,
|
||||
"services",
|
||||
"s",
|
||||
[]string{},
|
||||
"restore specific services",
|
||||
)
|
||||
|
||||
AppRestoreCommand.Flags().StringArrayVarP(
|
||||
&volumes,
|
||||
"volumes",
|
||||
"v",
|
||||
[]string{},
|
||||
"restore specific volumes",
|
||||
)
|
||||
|
||||
AppRestoreCommand.Flags().BoolVarP(
|
||||
&hooks,
|
||||
"hooks",
|
||||
"H",
|
||||
false,
|
||||
"enable pre/post-hook command execution",
|
||||
)
|
||||
|
||||
AppRestoreCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,185 +3,341 @@ package app
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/tagcmp"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appRollbackCommand = &cli.Command{
|
||||
Name: "rollback",
|
||||
Usage: "Roll an app back to a previous version",
|
||||
Aliases: []string{"rl"},
|
||||
ArgsUsage: "<app>",
|
||||
Flags: []cli.Flag{
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.DontWaitConvergeFlag,
|
||||
var AppRollbackCommand = &cobra.Command{
|
||||
Use: "rollback <domain> [version] [flags]",
|
||||
Aliases: []string{"rl"},
|
||||
Short: "Roll an app back to a previous version",
|
||||
Long: `This command rolls an app back to a previous version.
|
||||
|
||||
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
|
||||
versions are supported values for "[version]".
|
||||
|
||||
It is possible to "--force/-f" an downgrade if you want to re-deploy a specific
|
||||
version.
|
||||
|
||||
Only the deployed version is consulted when trying to determine what downgrades
|
||||
are available. The live deployment version is the "source of truth" in this
|
||||
case. The stored .env version is not consulted.
|
||||
|
||||
A downgrade can be destructive, please ensure you have a copy of your app data
|
||||
beforehand. See "abra app backup" for more.`,
|
||||
Example: ` # standard rollback
|
||||
abra app rollback 1312.net
|
||||
|
||||
# rollback to specific version
|
||||
abra app rollback 1312.net 2.0.0+1.2.3`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.RecipeVersionComplete(app.Recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Description: `
|
||||
This command rolls an app back to a previous version if one exists.
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var (
|
||||
downgradeWarnMessages []string
|
||||
chosenDowngrade string
|
||||
availableDowngrades []string
|
||||
)
|
||||
|
||||
You may pass "--force/-f" to downgrade to the same version again. This can be
|
||||
useful if the container runtime has gotten into a weird state.
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
This action could be destructive, please ensure you have a copy of your app
|
||||
data beforehand - see "abra app backup <app>" for more.
|
||||
|
||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
recipes.
|
||||
`,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := recipe.EnsureUpToDate(app.Type); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := recipe.Get(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
deployMeta, err := ensureDeployed(cl, app)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
if err := lint.LintForErrors(app.Recipe); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
versions, err := app.Recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 && !internal.Chaos {
|
||||
logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Type)
|
||||
}
|
||||
|
||||
var availableDowngrades []string
|
||||
if deployedVersion == "unknown" {
|
||||
// NOTE(d1): we've no idea what the live deployment version is, so every
|
||||
// possible downgrade can be shown. it's up to the user to make the choice
|
||||
if deployMeta.Version == config.UNKNOWN_DEFAULT {
|
||||
availableDowngrades = versions
|
||||
logrus.Warnf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if deployedVersion != "unknown" && !internal.Chaos {
|
||||
for _, version := range versions {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if parsedVersion != parsedDeployedVersion && parsedVersion.IsLessThan(parsedDeployedVersion) {
|
||||
availableDowngrades = append(availableDowngrades, version)
|
||||
}
|
||||
if len(args) == 2 && args[1] != "" {
|
||||
chosenDowngrade = args[1]
|
||||
|
||||
if err := validateDowngradeVersionArg(chosenDowngrade, app, deployMeta); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if len(availableDowngrades) == 0 {
|
||||
logrus.Info("no available downgrades, you're on oldest ✌️")
|
||||
return nil
|
||||
}
|
||||
availableDowngrades = append(availableDowngrades, chosenDowngrade)
|
||||
}
|
||||
|
||||
availableDowngrades = internal.ReverseStringList(availableDowngrades)
|
||||
|
||||
var chosenDowngrade string
|
||||
if !internal.Chaos {
|
||||
if internal.Force {
|
||||
chosenDowngrade = availableDowngrades[0]
|
||||
logrus.Debugf("choosing %s as version to downgrade to (--force)", chosenDowngrade)
|
||||
} else {
|
||||
prompt := &survey.Select{
|
||||
Message: fmt.Sprintf("Please select a downgrade (current version: %s):", deployedVersion),
|
||||
Options: availableDowngrades,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureVersion(app.Type, chosenDowngrade); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Chaos {
|
||||
logrus.Warn("chaos mode engaged")
|
||||
var err error
|
||||
chosenDowngrade, err = recipe.ChaosVersion(app.Type)
|
||||
if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenDowngrade == "" {
|
||||
downgradeAvailable, err := ensureDowngradesAvailable(versions, &availableDowngrades, deployMeta)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !downgradeAvailable {
|
||||
log.Info("no available downgrades")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh")
|
||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
||||
if internal.Force || internal.NoInput || chosenDowngrade != "" {
|
||||
if len(availableDowngrades) > 0 {
|
||||
chosenDowngrade = availableDowngrades[len(availableDowngrades)-1]
|
||||
}
|
||||
} else {
|
||||
if err := chooseDowngrade(availableDowngrades, deployMeta, &chosenDowngrade); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Force &&
|
||||
chosenDowngrade == "" &&
|
||||
deployMeta.Version != config.UNKNOWN_DEFAULT {
|
||||
chosenDowngrade = deployMeta.Version
|
||||
}
|
||||
|
||||
if chosenDowngrade == "" {
|
||||
log.Fatal("unknown deployed version, unable to downgrade")
|
||||
}
|
||||
|
||||
log.Debugf("choosing %s as version to rollback", chosenDowngrade)
|
||||
|
||||
if _, err := app.Recipe.EnsureVersion(chosenDowngrade); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
for k, v := range abraShEnv {
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
}
|
||||
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !internal.Force {
|
||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
appPkg.ExposeAllEnv(stackName, compose, app.Env)
|
||||
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
|
||||
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
|
||||
if internal.Chaos {
|
||||
appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
|
||||
}
|
||||
appPkg.SetUpdateLabel(compose, stackName, app.Env)
|
||||
|
||||
// NOTE(d1): no release notes implemeneted for rolling back
|
||||
if err := internal.DeployOverview(
|
||||
app,
|
||||
deployMeta.Version,
|
||||
chosenDowngrade,
|
||||
"",
|
||||
downgradeWarnMessages,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil {
|
||||
logrus.Fatal(err)
|
||||
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(
|
||||
cl,
|
||||
deployOpts,
|
||||
compose,
|
||||
stackName,
|
||||
app.Server,
|
||||
internal.DontWaitConverge,
|
||||
f,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.WriteRecipeVersion(chosenDowngrade, false); err != nil {
|
||||
log.Fatalf("writing recipe version failed: %s", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// chooseDowngrade prompts the user to choose an downgrade interactively.
|
||||
func chooseDowngrade(
|
||||
availableDowngrades []string,
|
||||
deployMeta stack.DeployMeta,
|
||||
chosenDowngrade *string,
|
||||
) error {
|
||||
msg := fmt.Sprintf("please select a downgrade (version: %s):", deployMeta.Version)
|
||||
|
||||
if deployMeta.IsChaos {
|
||||
chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)
|
||||
|
||||
msg = fmt.Sprintf(
|
||||
"please select a downgrade (version: %s, chaos: %s):",
|
||||
deployMeta.Version,
|
||||
chaosVersion,
|
||||
)
|
||||
}
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: msg,
|
||||
Options: internal.SortVersionsDesc(availableDowngrades),
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, chosenDowngrade); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDownpgradeVersionArg validates the specific version.
|
||||
func validateDowngradeVersionArg(
|
||||
specificVersion string,
|
||||
app app.App,
|
||||
deployMeta stack.DeployMeta,
|
||||
) error {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("current deployment '%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
|
||||
}
|
||||
|
||||
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
|
||||
}
|
||||
|
||||
if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) &&
|
||||
!parsedSpecificVersion.Equals(parsedDeployedVersion) {
|
||||
return fmt.Errorf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
|
||||
}
|
||||
|
||||
if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
|
||||
return fmt.Errorf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDowngradesAvailable ensures that there are available downgrades.
|
||||
func ensureDowngradesAvailable(
|
||||
versions []string,
|
||||
availableDowngrades *[]string,
|
||||
deployMeta stack.DeployMeta,
|
||||
) (bool, error) {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, version := range versions {
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if parsedVersion.IsLessThan(parsedDeployedVersion) &&
|
||||
!(parsedVersion.Equals(parsedDeployedVersion)) {
|
||||
*availableDowngrades = append(*availableDowngrades, version)
|
||||
}
|
||||
}
|
||||
|
||||
if len(*availableDowngrades) == 0 && !internal.Force {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppRollbackCommand.Flags().BoolVarP(
|
||||
&internal.Force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"perform action without further prompt",
|
||||
)
|
||||
|
||||
AppRollbackCommand.Flags().BoolVarP(
|
||||
&internal.NoDomainChecks,
|
||||
"no-domain-checks",
|
||||
"D",
|
||||
false,
|
||||
"disable public DNS checks",
|
||||
)
|
||||
|
||||
AppRollbackCommand.Flags().BoolVarP(
|
||||
&internal.DontWaitConverge, "no-converge-checks",
|
||||
"c",
|
||||
false,
|
||||
"disable converge logic checks",
|
||||
)
|
||||
}
|
||||
|
||||
118
cli/app/run.go
118
cli/app/run.go
@ -1,98 +1,114 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/docker/api/types"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var user string
|
||||
var userFlag = &cli.StringFlag{
|
||||
Name: "user",
|
||||
Value: "",
|
||||
Destination: &user,
|
||||
}
|
||||
var AppRunCommand = &cobra.Command{
|
||||
Use: "run <domain> <service> <cmd> [[args] [flags] | [flags] -- [args]]",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Run a command inside a service container",
|
||||
Example: ` # run <cmd> with args/flags
|
||||
abra app run 1312.net app -- ls -lha
|
||||
|
||||
var noTTY bool
|
||||
var noTTYFlag = &cli.BoolFlag{
|
||||
Name: "no-tty",
|
||||
Value: false,
|
||||
Destination: &noTTY,
|
||||
}
|
||||
# run <cmd> without args/flags
|
||||
abra app run 1312.net app bash --user nobody
|
||||
|
||||
var appRunCommand = &cli.Command{
|
||||
Name: "run",
|
||||
Flags: []cli.Flag{
|
||||
noTTYFlag,
|
||||
userFlag,
|
||||
# run <cmd> with both kinds of args/flags
|
||||
abra app run 1312.net app --user nobody -- ls -lha`,
|
||||
Args: cobra.MinimumNArgs(3),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
return autocomplete.ServiceNameComplete(args[0])
|
||||
case 2:
|
||||
return autocomplete.CommandNameComplete(args[0])
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Aliases: []string{"r"},
|
||||
ArgsUsage: "<service> <args>...",
|
||||
Usage: "Run a command in a service container",
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() < 2 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no <service> provided?"))
|
||||
}
|
||||
|
||||
if c.Args().Len() < 3 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no <args> provided?"))
|
||||
}
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
serviceName := c.Args().Get(1)
|
||||
stackAndServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
||||
serviceName := args[1]
|
||||
stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), serviceName)
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", stackAndServiceName)
|
||||
|
||||
targetContainer, err := containerPkg.GetContainer(c.Context, cl, filters, true)
|
||||
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := c.Args().Slice()[2:]
|
||||
execCreateOpts := types.ExecConfig{
|
||||
userCmd := args[2:]
|
||||
execCreateOpts := containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: cmd,
|
||||
Cmd: userCmd,
|
||||
Detach: false,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
if user != "" {
|
||||
execCreateOpts.User = user
|
||||
if runAsUser != "" {
|
||||
execCreateOpts.User = runAsUser
|
||||
}
|
||||
if noTTY {
|
||||
execCreateOpts.Tty = false
|
||||
}
|
||||
|
||||
// FIXME: avoid instantiating a new CLI
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
noTTY bool
|
||||
runAsUser string
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppRunCommand.Flags().BoolVarP(&noTTY,
|
||||
"no-tty",
|
||||
"t",
|
||||
false,
|
||||
"do not request a TTY",
|
||||
)
|
||||
|
||||
AppRunCommand.Flags().StringVarP(
|
||||
&runAsUser,
|
||||
"user",
|
||||
"u",
|
||||
"",
|
||||
"run command as user",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,232 +1,331 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var allSecrets bool
|
||||
var allSecretsFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Aliases: []string{"A"},
|
||||
Value: false,
|
||||
Destination: &allSecrets,
|
||||
Usage: "Generate all secrets",
|
||||
}
|
||||
|
||||
var appSecretGenerateCommand = &cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate secrets",
|
||||
ArgsUsage: "<secret> <version>",
|
||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() == 1 && !allSecrets {
|
||||
err := errors.New("missing arguments <secret>/<version> or '--all'")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
|
||||
if c.Args().Get(1) != "" && allSecrets {
|
||||
err := errors.New("cannot use '<secret> <version>' and '--all' together")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
|
||||
secretsToCreate := make(map[string]string)
|
||||
secretEnvVars := secret.ReadSecretEnvVars(app.Env)
|
||||
if allSecrets {
|
||||
secretsToCreate = secretEnvVars
|
||||
} else {
|
||||
secretName := c.Args().Get(1)
|
||||
secretVersion := c.Args().Get(2)
|
||||
matches := false
|
||||
for sec := range secretEnvVars {
|
||||
parsed := secret.ParseSecretEnvVarName(sec)
|
||||
if secretName == parsed {
|
||||
secretsToCreate[sec] = secretVersion
|
||||
}
|
||||
}
|
||||
if !matches {
|
||||
logrus.Fatalf("%s doesn't exist in the env config?", secretName)
|
||||
var AppSecretGenerateCommand = &cobra.Command{
|
||||
Use: "generate <domain> [[secret] [version] | --all] [flags]",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Generate secrets",
|
||||
Args: cobra.RangeArgs(1, 3),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.SecretComplete(app.Recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretVals, err := secret.GenerateSecrets(secretsToCreate, app.StackName(), app.Server)
|
||||
if len(args) <= 2 && !generateAllSecrets {
|
||||
log.Fatal("missing arguments [secret]/[version] or '--all'")
|
||||
}
|
||||
|
||||
if len(args) > 2 && generateAllSecrets {
|
||||
log.Fatal("cannot use '[secret] [version]' and '--all' together")
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if internal.Pass {
|
||||
secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !generateAllSecrets {
|
||||
secretName := args[1]
|
||||
secretVersion := args[2]
|
||||
s, ok := secrets[secretName]
|
||||
if !ok {
|
||||
log.Fatalf("%s doesn't exist in the env config?", secretName)
|
||||
}
|
||||
s.Version = secretVersion
|
||||
secrets = map[string]secret.Secret{
|
||||
secretName: s,
|
||||
}
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretVals, err := secret.GenerateSecrets(cl, secrets, app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if storeInPass {
|
||||
for name, data := range secretVals {
|
||||
if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(secretVals) == 0 {
|
||||
logrus.Warn("no secrets generated")
|
||||
log.Warn("no secrets generated")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tableCol := []string{"name", "value"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
for name, val := range secretVals {
|
||||
table.Append([]string{name, val})
|
||||
headers := []string{"NAME", "VALUE"}
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
table.Render()
|
||||
logrus.Warn("generated secrets are not shown again, please take note of them *now*")
|
||||
|
||||
return nil
|
||||
table.Headers(headers...)
|
||||
|
||||
var rows [][]string
|
||||
for name, val := range secretVals {
|
||||
row := []string{name, val}
|
||||
rows = append(rows, row)
|
||||
table.Row(row...)
|
||||
}
|
||||
|
||||
if internal.MachineReadable {
|
||||
out, err := formatter.ToJSON(headers, rows)
|
||||
if err != nil {
|
||||
log.Fatal("unable to render to JSON: %s", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
return
|
||||
}
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Warnf(
|
||||
"generated secrets %s shown again, please take note of them %s",
|
||||
formatter.BoldStyle.Render("NOT"),
|
||||
formatter.BoldStyle.Render("NOW"),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretInsertCommand = &cli.Command{
|
||||
Name: "insert",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Insert secret",
|
||||
Flags: []cli.Flag{internal.PassFlag},
|
||||
ArgsUsage: "<app> <secret-name> <version> <data>",
|
||||
Description: `
|
||||
This command inserts a secret into an app environment.
|
||||
var AppSecretInsertCommand = &cobra.Command{
|
||||
Use: "insert <domain> <secret> <version> <data> [flags]",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Insert secret",
|
||||
Long: `This command inserts a secret into an app environment.
|
||||
|
||||
Arbitrary secret insertion is not supported. Secrets that are inserted must
|
||||
match those configured in the recipe beforehand.
|
||||
|
||||
This can be useful when you want to manually generate secrets for an app
|
||||
environment. Typically, you can let Abra generate them for you on app creation
|
||||
(see "abra app new --secrets" for more).
|
||||
(see "abra app new --secrets/-S" for more).`,
|
||||
Example: ` # insert regular secret
|
||||
abra app secret insert 1312.net my_secret v1 mySuperSecret
|
||||
|
||||
Example:
|
||||
# insert secret as file
|
||||
abra app secret insert 1312.net my_secret v1 secret.txt -f`,
|
||||
Args: cobra.MinimumNArgs(4),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.SecretComplete(app.Recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
abra app secret insert myapp db_pass v1 mySecretPassword
|
||||
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() != 4 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
name := c.Args().Get(1)
|
||||
version := c.Args().Get(2)
|
||||
data := c.Args().Get(3)
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
name := args[1]
|
||||
version := args[2]
|
||||
data := args[3]
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var isRecipeSecret bool
|
||||
for secretName := range secrets {
|
||||
if secretName == name {
|
||||
isRecipeSecret = true
|
||||
}
|
||||
}
|
||||
if !isRecipeSecret {
|
||||
log.Fatalf("no secret %s available for recipe %s?", name, app.Recipe.Name)
|
||||
}
|
||||
|
||||
if insertFromFile {
|
||||
raw, err := os.ReadFile(data)
|
||||
if err != nil {
|
||||
log.Fatalf("reading secret from file: %s", err)
|
||||
}
|
||||
data = string(raw)
|
||||
}
|
||||
|
||||
if trimInput {
|
||||
data = strings.TrimSpace(data)
|
||||
}
|
||||
|
||||
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
|
||||
if err := client.StoreSecret(secretName, data, app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := client.StoreSecret(cl, secretName, data); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if internal.Pass {
|
||||
if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Infof("%s successfully stored on server", secretName)
|
||||
|
||||
if storeInPass {
|
||||
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretRmCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove a secret",
|
||||
Aliases: []string{"rm"},
|
||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
||||
ArgsUsage: "<app> <secret-name>",
|
||||
Description: `
|
||||
This command removes a secret from an app environment.
|
||||
// secretRm removes a secret.
|
||||
func secretRm(cl *dockerClient.Client, app appPkg.App, secretName, parsed string) error {
|
||||
if err := cl.SecretRemove(context.Background(), secretName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Example:
|
||||
log.Infof("deleted %s successfully from server", secretName)
|
||||
|
||||
abra app secret remove myapp db_pass
|
||||
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Get(1) != "" && allSecrets {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
|
||||
if removeFromPass {
|
||||
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Args().Get(1) == "" && !allSecrets {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
|
||||
log.Infof("deleted %s successfully from local pass store", secretName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var AppSecretRmCommand = &cobra.Command{
|
||||
Use: "remove <domain> [[secret] | --all] [flags]",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a secret",
|
||||
Long: `This command removes a secret from an app environment.
|
||||
|
||||
Arbitrary secret removal is not supported. Secrets that are removed must
|
||||
match those configured in the recipe beforehand.`,
|
||||
Example: " abra app secret rm 1312.net oauth_key",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
if !rmAllSecrets {
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.SecretComplete(app.Recipe.Name)
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if len(args) == 2 && rmAllSecrets {
|
||||
log.Fatal("cannot use [secret] and --all/-a together")
|
||||
}
|
||||
|
||||
if len(args) != 2 && !rmAllSecrets {
|
||||
log.Fatal("no secret(s) specified?")
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
|
||||
filters, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
secretToRm := c.Args().Get(1)
|
||||
for _, cont := range secretList {
|
||||
secretName := cont.Spec.Annotations.Name
|
||||
parsed := secret.ParseGeneratedSecretName(secretName, app)
|
||||
if allSecrets {
|
||||
if err := cl.SecretRemove(c.Context, secretName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if internal.Pass {
|
||||
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if parsed == secretToRm {
|
||||
if err := cl.SecretRemove(c.Context, secretName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if internal.Pass {
|
||||
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretLsCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all secrets",
|
||||
Aliases: []string{"ls"},
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
secrets := secret.ReadSecretEnvVars(app.Env)
|
||||
|
||||
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
remoteSecretNames := make(map[string]bool)
|
||||
@ -234,41 +333,230 @@ var appSecretLsCommand = &cli.Command{
|
||||
remoteSecretNames[cont.Spec.Annotations.Name] = true
|
||||
}
|
||||
|
||||
for sec := range secrets {
|
||||
createdRemote := false
|
||||
secretName := secret.ParseSecretEnvVarName(sec)
|
||||
secVal, err := secret.ParseSecretEnvVarValue(secrets[sec])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, secVal.Version)
|
||||
var secretToRm string
|
||||
if len(args) == 2 {
|
||||
secretToRm = args[1]
|
||||
}
|
||||
|
||||
match := false
|
||||
for secretName, val := range secrets {
|
||||
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version)
|
||||
if _, ok := remoteSecretNames[secretRemoteName]; ok {
|
||||
createdRemote = true
|
||||
if secretToRm != "" {
|
||||
if secretName == secretToRm {
|
||||
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
} else {
|
||||
match = true
|
||||
|
||||
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
tableRow := []string{secretName, secVal.Version, secretRemoteName, strconv.FormatBool(createdRemote)}
|
||||
table.Append(tableRow)
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
} else {
|
||||
logrus.Warnf("no secrets stored for %s", app.Name)
|
||||
if !match && secretToRm != "" {
|
||||
log.Fatalf("%s doesn't exist on server?", secretToRm)
|
||||
}
|
||||
|
||||
return nil
|
||||
if !match {
|
||||
log.Fatal("no secrets to remove?")
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
var appSecretCommand = &cli.Command{
|
||||
Name: "secret",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Manage app secrets",
|
||||
ArgsUsage: "<command>",
|
||||
Subcommands: []*cli.Command{
|
||||
appSecretGenerateCommand,
|
||||
appSecretInsertCommand,
|
||||
appSecretRmCommand,
|
||||
appSecretLsCommand,
|
||||
var AppSecretLsCommand = &cobra.Command{
|
||||
Use: "list <domain>",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List all secrets",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{"NAME", "VERSION", "GENERATED NAME", "CREATED ON SERVER"}
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table.Headers(headers...)
|
||||
|
||||
secStats, err := secret.PollSecretsStatus(cl, app)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var rows [][]string
|
||||
for _, secStat := range secStats {
|
||||
row := []string{
|
||||
secStat.LocalName,
|
||||
secStat.Version,
|
||||
secStat.RemoteName,
|
||||
strconv.FormatBool(secStat.CreatedOnRemote),
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
table.Row(row...)
|
||||
}
|
||||
|
||||
if len(rows) > 0 {
|
||||
if internal.MachineReadable {
|
||||
out, err := formatter.ToJSON(headers, rows)
|
||||
if err != nil {
|
||||
log.Fatal("unable to render to JSON: %s", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
return
|
||||
}
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Warnf("no secrets stored for %s", app.Name)
|
||||
},
|
||||
}
|
||||
|
||||
var AppSecretCommand = &cobra.Command{
|
||||
Use: "secret [cmd] [args] [flags]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Manage app secrets",
|
||||
}
|
||||
|
||||
var (
|
||||
storeInPass bool
|
||||
insertFromFile bool
|
||||
trimInput bool
|
||||
rmAllSecrets bool
|
||||
generateAllSecrets bool
|
||||
removeFromPass bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppSecretGenerateCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
|
||||
AppSecretGenerateCommand.Flags().BoolVarP(
|
||||
&storeInPass,
|
||||
"pass",
|
||||
"p",
|
||||
false,
|
||||
"store generated secrets in a local pass store",
|
||||
)
|
||||
|
||||
AppSecretGenerateCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppSecretGenerateCommand.Flags().BoolVarP(
|
||||
&generateAllSecrets,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"generate all secrets",
|
||||
)
|
||||
|
||||
AppSecretInsertCommand.Flags().BoolVarP(
|
||||
&storeInPass,
|
||||
"pass",
|
||||
"p",
|
||||
false,
|
||||
"store generated secrets in a local pass store",
|
||||
)
|
||||
|
||||
AppSecretInsertCommand.Flags().BoolVarP(
|
||||
&insertFromFile,
|
||||
"file",
|
||||
"f",
|
||||
false,
|
||||
"treat input as a file",
|
||||
)
|
||||
|
||||
AppSecretInsertCommand.Flags().BoolVarP(
|
||||
&trimInput,
|
||||
"trim",
|
||||
"t",
|
||||
false,
|
||||
"trim input",
|
||||
)
|
||||
|
||||
AppSecretInsertCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppSecretRmCommand.Flags().BoolVarP(
|
||||
&rmAllSecrets,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"remove all secrets",
|
||||
)
|
||||
|
||||
AppSecretRmCommand.Flags().BoolVarP(
|
||||
&removeFromPass,
|
||||
"pass",
|
||||
"p",
|
||||
false,
|
||||
"remove generated secrets from a local pass store",
|
||||
)
|
||||
|
||||
AppSecretRmCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppSecretLsCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
AppSecretLsCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
}
|
||||
|
||||
96
cli/app/services.go
Normal file
96
cli/app/services.go
Normal file
@ -0,0 +1,96 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
containerTypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AppServicesCommand = &cobra.Command{
|
||||
Use: "services <domain> [flags]",
|
||||
Aliases: []string{"sr"},
|
||||
Short: "Display all services of an app",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
filters, err := app.Filters(true, true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
containers, err := cl.ContainerList(context.Background(), containerTypes.ListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{"SERVICE (SHORT)", "SERVICE (LONG)"}
|
||||
table.Headers(headers...)
|
||||
|
||||
var rows [][]string
|
||||
for _, container := range containers {
|
||||
var containerNames []string
|
||||
for _, containerName := range container.Names {
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
containerNames = append(containerNames, trimmed)
|
||||
}
|
||||
|
||||
serviceShortName := service.ContainerToServiceName(container.Names, app.StackName())
|
||||
serviceLongName := fmt.Sprintf("%s_%s", app.StackName(), serviceShortName)
|
||||
|
||||
row := []string{
|
||||
serviceShortName,
|
||||
serviceLongName,
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
table.Rows(rows...)
|
||||
|
||||
if len(rows) > 0 {
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -1,53 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appUndeployCommand = &cli.Command{
|
||||
Name: "undeploy",
|
||||
var AppUndeployCommand = &cobra.Command{
|
||||
Use: "undeploy <domain> [flags]",
|
||||
Aliases: []string{"un"},
|
||||
Usage: "Undeploy an app",
|
||||
Description: `
|
||||
This does not destroy any of the application data. However, you should remain
|
||||
vigilant, as your swarm installation will consider any previously attached
|
||||
volumes as eligiblef or pruning once undeployed.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Short: "Undeploy an app",
|
||||
Long: `This does not destroy any application data.
|
||||
|
||||
However, you should remain vigilant, as your swarm installation will consider
|
||||
any previously attached volumes as eligible for pruning once undeployed.
|
||||
|
||||
Passing "--prune/-p" does not remove those volumes.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := app.Recipe.EnsureExists(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
log.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := internal.DeployOverview(
|
||||
app,
|
||||
deployMeta.Version,
|
||||
config.NO_DOMAIN_DEFAULT,
|
||||
"",
|
||||
nil,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
|
||||
if err := stack.RunRemove(c.Context, cl, rmOpts); err != nil {
|
||||
logrus.Fatal(err)
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
opts := stack.Deploy{Composefiles: composeFiles, Namespace: stackName}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Info("initialising undeploy")
|
||||
|
||||
rmOpts := stack.Remove{
|
||||
Namespaces: []string{stackName},
|
||||
Detach: false,
|
||||
}
|
||||
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if prune {
|
||||
if err := pruneApp(cl, app); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("undeploy succeeded 🟢")
|
||||
|
||||
if err := app.WriteRecipeVersion(deployMeta.Version, false); err != nil {
|
||||
log.Fatalf("writing recipe version failed: %s", err)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
// pruneApp runs the equivalent of a "docker system prune" but only filtering
|
||||
// against resources connected with the app deployment. It is not a system wide
|
||||
// prune. Volumes are not pruned to avoid unwated data loss.
|
||||
func pruneApp(cl *dockerClient.Client, app appPkg.App) error {
|
||||
stackName := app.StackName()
|
||||
ctx := context.Background()
|
||||
|
||||
pruneFilters := filters.NewArgs()
|
||||
stackSearch := fmt.Sprintf("%s*", stackName)
|
||||
pruneFilters.Add("label", stackSearch)
|
||||
cr, err := cl.ContainersPrune(ctx, pruneFilters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
|
||||
log.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
|
||||
|
||||
nr, err := cl.NetworksPrune(ctx, pruneFilters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("networks pruned: %d", len(nr.NetworksDeleted))
|
||||
|
||||
ir, err := cl.ImagesPrune(ctx, pruneFilters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
|
||||
log.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
prune bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
AppUndeployCommand.Flags().BoolVarP(
|
||||
&prune,
|
||||
"prune",
|
||||
"p",
|
||||
false,
|
||||
"prune unused containers, networks, and dangling images",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,197 +1,461 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appUpgradeCommand = &cli.Command{
|
||||
Name: "upgrade",
|
||||
Aliases: []string{"up"},
|
||||
Usage: "Upgrade an app",
|
||||
ArgsUsage: "<app>",
|
||||
Flags: []cli.Flag{
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.NoDomainChecksFlag,
|
||||
var AppUpgradeCommand = &cobra.Command{
|
||||
Use: "upgrade <domain> [version] [flags]",
|
||||
Aliases: []string{"up"},
|
||||
Short: "Upgrade an app",
|
||||
Long: `Upgrade an app.
|
||||
|
||||
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
|
||||
versions are supported values for "[version]".
|
||||
|
||||
It is possible to "--force/-f" an upgrade if you want to re-deploy a specific
|
||||
version.
|
||||
|
||||
Only the deployed version is consulted when trying to determine what upgrades
|
||||
are available. The live deployment version is the "source of truth" in this
|
||||
case. The stored .env version is not consulted.
|
||||
|
||||
An upgrade can be destructive, please ensure you have a copy of your app data
|
||||
beforehand. See "abra app backup" for more.`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.AppNameComplete()
|
||||
case 1:
|
||||
app, err := appPkg.Get(args[0])
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return autocomplete.RecipeVersionComplete(app.Recipe.Name)
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Description: `
|
||||
This command supports upgrading an app. You can use it to choose and roll out a
|
||||
new upgrade to an existing app.
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var (
|
||||
upgradeWarnMessages []string
|
||||
chosenUpgrade string
|
||||
availableUpgrades []string
|
||||
upgradeReleaseNotes string
|
||||
)
|
||||
|
||||
This command specifically supports incrementing the version of running apps, as
|
||||
opposed to "abra app deploy <app>" which will not change the version of a
|
||||
deployed app.
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
You may pass "--force/-f" to upgrade to the same version again. This can be
|
||||
useful if the container runtime has gotten into a weird state.
|
||||
|
||||
This action could be destructive, please ensure you have a copy of your app
|
||||
data beforehand - see "abra app backup <app>" for more.
|
||||
|
||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
recipes.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
if err := recipe.EnsureUpToDate(app.Type); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := recipe.Get(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := app.Recipe.Ensure(recipe.EnsureContext{
|
||||
Chaos: internal.Chaos,
|
||||
Offline: internal.Offline,
|
||||
// Ignore the env version for now, to make sure we are at the latest commit.
|
||||
// This enables us to get release notes, that were added after a release.
|
||||
IgnoreEnvVersion: true,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
deployMeta, err := ensureDeployed(cl, app)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
if err := lint.LintForErrors(app.Recipe); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
versions, err := app.Recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 && !internal.Chaos {
|
||||
logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Type)
|
||||
}
|
||||
|
||||
var availableUpgrades []string
|
||||
if deployedVersion == "uknown" {
|
||||
// NOTE(d1): we've no idea what the live deployment version is, so every
|
||||
// possible upgrade can be shown. it's up to the user to make the choice
|
||||
if deployMeta.Version == config.UNKNOWN_DEFAULT {
|
||||
availableUpgrades = versions
|
||||
logrus.Warnf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if deployedVersion != "unknown" && !internal.Chaos {
|
||||
for _, version := range versions {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if parsedVersion.IsGreaterThan(parsedDeployedVersion) {
|
||||
availableUpgrades = append(availableUpgrades, version)
|
||||
}
|
||||
if len(args) == 2 && args[1] != "" {
|
||||
chosenUpgrade = args[1]
|
||||
|
||||
if err := validateUpgradeVersionArg(chosenUpgrade, app, deployMeta); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if len(availableUpgrades) == 0 && !internal.Force {
|
||||
logrus.Infof("no available upgrades, you're on latest (%s) ✌️", deployedVersion)
|
||||
return nil
|
||||
}
|
||||
availableUpgrades = append(availableUpgrades, chosenUpgrade)
|
||||
}
|
||||
|
||||
availableUpgrades = internal.ReverseStringList(availableUpgrades)
|
||||
|
||||
var chosenUpgrade string
|
||||
if len(availableUpgrades) > 0 && !internal.Chaos {
|
||||
if internal.Force {
|
||||
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
|
||||
logrus.Debugf("choosing %s as version to upgrade to", chosenUpgrade)
|
||||
} else {
|
||||
prompt := &survey.Select{
|
||||
Message: fmt.Sprintf("Please select an upgrade (current version: %s):", deployedVersion),
|
||||
Options: availableUpgrades,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if release notes written after git tag published, read them before we
|
||||
// check out the tag and then they'll appear to be missing. this covers
|
||||
// when we obviously will forget to write release notes before publishing
|
||||
releaseNotes, err := internal.GetReleaseNotes(app.Type, chosenUpgrade)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Chaos {
|
||||
logrus.Warn("chaos mode engaged")
|
||||
var err error
|
||||
chosenUpgrade, err = recipe.ChaosVersion(app.Type)
|
||||
if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenUpgrade == "" {
|
||||
upgradeAvailable, err := ensureUpgradesAvailable(app, versions, &availableUpgrades, deployMeta)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !upgradeAvailable {
|
||||
log.Info("no available upgrades")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh")
|
||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
||||
if internal.Force || internal.NoInput || chosenUpgrade != "" {
|
||||
if len(availableUpgrades) > 0 {
|
||||
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
|
||||
}
|
||||
} else {
|
||||
if err := chooseUpgrade(availableUpgrades, deployMeta, &chosenUpgrade); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Force &&
|
||||
chosenUpgrade == "" &&
|
||||
deployMeta.Version != config.UNKNOWN_DEFAULT {
|
||||
chosenUpgrade = deployMeta.Version
|
||||
}
|
||||
|
||||
if chosenUpgrade == "" {
|
||||
log.Fatal("unknown deployed version, unable to upgrade")
|
||||
}
|
||||
|
||||
log.Debugf("choosing %s as version to upgrade", chosenUpgrade)
|
||||
|
||||
// Get the release notes before checking out the new version in the
|
||||
// recipe. This enables us to get release notes, that were added after
|
||||
// a release.
|
||||
if err := getReleaseNotes(app, versions, chosenUpgrade, deployMeta, &upgradeReleaseNotes); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := app.Recipe.EnsureVersion(chosenUpgrade); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
for k, v := range abraShEnv {
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
}
|
||||
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil {
|
||||
logrus.Fatal(err)
|
||||
appPkg.ExposeAllEnv(stackName, compose, app.Env)
|
||||
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
|
||||
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
|
||||
if internal.Chaos {
|
||||
appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
|
||||
}
|
||||
appPkg.SetUpdateLabel(compose, stackName, app.Env)
|
||||
|
||||
envVars, err := appPkg.CheckEnv(app)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil {
|
||||
logrus.Fatal(err)
|
||||
for _, envVar := range envVars {
|
||||
if !envVar.Present {
|
||||
upgradeWarnMessages = append(upgradeWarnMessages,
|
||||
fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if showReleaseNotes {
|
||||
fmt.Print(upgradeReleaseNotes)
|
||||
return
|
||||
}
|
||||
|
||||
if upgradeReleaseNotes == "" {
|
||||
upgradeWarnMessages = append(
|
||||
upgradeWarnMessages,
|
||||
fmt.Sprintf("no release notes available for %s", chosenUpgrade),
|
||||
)
|
||||
}
|
||||
|
||||
if err := internal.DeployOverview(
|
||||
app,
|
||||
deployMeta.Version,
|
||||
chosenUpgrade,
|
||||
upgradeReleaseNotes,
|
||||
upgradeWarnMessages,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(
|
||||
cl,
|
||||
deployOpts,
|
||||
compose,
|
||||
stackName,
|
||||
app.Server,
|
||||
internal.DontWaitConverge,
|
||||
f,
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
|
||||
if ok && !internal.DontWaitConverge {
|
||||
log.Debugf("run the following post-deploy commands: %s", postDeployCmds)
|
||||
|
||||
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
|
||||
log.Fatalf("attempting to run post deploy commands, saw: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.WriteRecipeVersion(chosenUpgrade, false); err != nil {
|
||||
log.Fatalf("writing recipe version failed: %s", err)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
// chooseUpgrade prompts the user to choose an upgrade interactively.
|
||||
func chooseUpgrade(
|
||||
availableUpgrades []string,
|
||||
deployMeta stack.DeployMeta,
|
||||
chosenUpgrade *string,
|
||||
) error {
|
||||
msg := fmt.Sprintf("please select an upgrade (version: %s):", deployMeta.Version)
|
||||
|
||||
if deployMeta.IsChaos {
|
||||
chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)
|
||||
|
||||
msg = fmt.Sprintf(
|
||||
"please select an upgrade (version: %s, chaos: %s):",
|
||||
deployMeta.Version,
|
||||
chaosVersion,
|
||||
)
|
||||
}
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: msg,
|
||||
Options: internal.SortVersionsDesc(availableUpgrades),
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, chosenUpgrade); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getReleaseNotes(
|
||||
app app.App,
|
||||
versions []string,
|
||||
chosenUpgrade string,
|
||||
deployMeta stack.DeployMeta,
|
||||
upgradeReleaseNotes *string,
|
||||
) error {
|
||||
parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing chosen upgrade version failed: %s", err)
|
||||
}
|
||||
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing deployment version failed: %s", err)
|
||||
}
|
||||
|
||||
for _, version := range internal.SortVersionsDesc(versions) {
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing recipe version failed: %s", err)
|
||||
}
|
||||
|
||||
if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
|
||||
parsedVersion.IsLessThan(parsedChosenUpgrade) {
|
||||
note, err := app.Recipe.GetReleaseNotes(version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if note != "" {
|
||||
// NOTE(d1): trim any final newline on the end of the note itself before
|
||||
// we manually handle newlines (for multiple release notes and
|
||||
// ensuring space between the warning messages)
|
||||
note = strings.TrimSuffix(note, "\n")
|
||||
|
||||
*upgradeReleaseNotes += fmt.Sprintf("%s\n", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureUpgradesAvailable ensures that there are available upgrades.
|
||||
func ensureUpgradesAvailable(
|
||||
app app.App,
|
||||
versions []string,
|
||||
availableUpgrades *[]string,
|
||||
deployMeta stack.DeployMeta,
|
||||
) (bool, error) {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parsing deployed version failed: %s", err)
|
||||
}
|
||||
|
||||
for _, version := range versions {
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parsing recipe version failed: %s", err)
|
||||
}
|
||||
|
||||
if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
|
||||
!(parsedVersion.Equals(parsedDeployedVersion)) {
|
||||
*availableUpgrades = append(*availableUpgrades, version)
|
||||
}
|
||||
}
|
||||
|
||||
if len(*availableUpgrades) == 0 && !internal.Force {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// validateUpgradeVersionArg validates the specific version.
|
||||
func validateUpgradeVersionArg(
|
||||
specificVersion string,
|
||||
app app.App,
|
||||
deployMeta stack.DeployMeta,
|
||||
) error {
|
||||
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
|
||||
}
|
||||
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("'%s' is not a known version", deployMeta.Version)
|
||||
}
|
||||
|
||||
if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) &&
|
||||
!parsedSpecificVersion.Equals(parsedDeployedVersion) {
|
||||
return fmt.Errorf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
|
||||
}
|
||||
|
||||
if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
|
||||
return fmt.Errorf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDeployed ensures the app is deployed and if so, returns deployment
|
||||
// meta info.
|
||||
func ensureDeployed(cl *dockerClient.Client, app app.App) (stack.DeployMeta, error) {
|
||||
log.Debugf("checking whether %s is already deployed", app.StackName())
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
return stack.DeployMeta{}, err
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
return stack.DeployMeta{}, fmt.Errorf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
return deployMeta, nil
|
||||
}
|
||||
|
||||
var showReleaseNotes bool
|
||||
|
||||
func init() {
|
||||
AppUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.Force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"perform action without further prompt",
|
||||
)
|
||||
|
||||
AppUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.NoDomainChecks,
|
||||
"no-domain-checks",
|
||||
"D",
|
||||
false,
|
||||
"disable public DNS checks",
|
||||
)
|
||||
|
||||
AppUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.DontWaitConverge, "no-converge-checks",
|
||||
"c",
|
||||
false,
|
||||
"disable converge logic checks",
|
||||
)
|
||||
|
||||
AppUpgradeCommand.Flags().BoolVarP(
|
||||
&showReleaseNotes,
|
||||
"releasenotes",
|
||||
"r",
|
||||
false,
|
||||
"only show release notes",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getImagePath returns the image name
|
||||
func getImagePath(image string) (string, error) {
|
||||
img, err := reference.ParseNormalizedNamed(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := reference.Path(img)
|
||||
if strings.Contains(path, "library") {
|
||||
path = strings.Split(path, "/")[1]
|
||||
}
|
||||
|
||||
logrus.Debugf("parsed %s from %s", path, image)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
var appVersionCommand = &cli.Command{
|
||||
Name: "version",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Show app versions",
|
||||
Description: `
|
||||
This command shows all information about versioning related to a deployed app.
|
||||
This includes the individual image names, tags and digests. But also the Co-op
|
||||
Cloud recipe version.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if deployedVersion == "unknown" {
|
||||
logrus.Fatalf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
recipeMeta, err := recipe.GetRecipeMeta(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
versionsMeta := make(map[string]recipe.ServiceMeta)
|
||||
for _, recipeVersion := range recipeMeta.Versions {
|
||||
if currentVersion, exists := recipeVersion[deployedVersion]; exists {
|
||||
versionsMeta = currentVersion
|
||||
}
|
||||
}
|
||||
|
||||
if len(versionsMeta) == 0 {
|
||||
logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion)
|
||||
}
|
||||
|
||||
tableCol := []string{"version", "service", "image", "digest"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table.SetAutoMergeCellsByColumnIndex([]int{0})
|
||||
|
||||
for serviceName, versionMeta := range versionsMeta {
|
||||
table.Append([]string{deployedVersion, serviceName, versionMeta.Image, versionMeta.Digest})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
@ -1,80 +1,161 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var appVolumeListCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List volumes associated with an app",
|
||||
Aliases: []string{"ls"},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
var AppVolumeListCommand = &cobra.Command{
|
||||
Use: "list <domain> [flags]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List volumes associated with an app",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table := formatter.CreateTable([]string{"driver", "volume name"})
|
||||
var volTable [][]string
|
||||
for _, volume := range volumeList {
|
||||
volRow := []string{
|
||||
volume.Driver,
|
||||
volume.Name,
|
||||
filters, err := app.Filters(false, true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
volumes, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{"NAME", "ON SERVER"}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table.Headers(headers...)
|
||||
|
||||
var rows [][]string
|
||||
for _, volume := range volumes {
|
||||
row := []string{volume.Name, volume.Mountpoint}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
table.Rows(rows...)
|
||||
|
||||
if len(rows) > 0 {
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
volTable = append(volTable, volRow)
|
||||
return
|
||||
}
|
||||
|
||||
table.AppendBulk(volTable)
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
} else {
|
||||
logrus.Warnf("no volumes created for %s", app.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
log.Warnf("no volumes created for %s", app.Name)
|
||||
},
|
||||
}
|
||||
|
||||
var appVolumeRemoveCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove volume(s) associated with an app",
|
||||
Description: `
|
||||
This command supports removing volumes associated with an app. The app in
|
||||
question must be undeployed before you try to remove volumes. See "abra app
|
||||
undeploy <app>" for more.
|
||||
var AppVolumeRemoveCommand = &cobra.Command{
|
||||
Use: "remove <domain> [volume] [flags]",
|
||||
Short: "Remove volume(s) associated with an app",
|
||||
Long: `Remove volumes associated with an app.
|
||||
|
||||
The app in question must be undeployed before you try to remove volumes. See
|
||||
"abra app undeploy <domain>" for more.
|
||||
|
||||
The command is interactive and will show a multiple select input which allows
|
||||
you to make a seclection. Use the "?" key to see more help on navigating this
|
||||
interface.
|
||||
|
||||
Passing "--force" will select all volumes for removal. Be careful.
|
||||
`,
|
||||
ArgsUsage: "<app>",
|
||||
Aliases: []string{"rm"},
|
||||
Flags: []cli.Flag{
|
||||
internal.ForceFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
Passing "--force/-f" will select all volumes for removal. Be careful.`,
|
||||
Example: ` # delete volumes interactively
|
||||
abra app volume rm 1312.net
|
||||
|
||||
volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
|
||||
# delete specific volume
|
||||
abra app volume rm 1312.net my_volume`,
|
||||
Aliases: []string{"rm"},
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.AppNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
var volumeToDelete string
|
||||
if len(args) == 2 {
|
||||
volumeToDelete = args[1]
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if deployMeta.IsDeployed {
|
||||
log.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
|
||||
}
|
||||
|
||||
filters, err := app.Filters(false, true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
volumeNames := client.GetVolumeNames(volumeList)
|
||||
|
||||
if volumeToDelete != "" {
|
||||
var exactMatch bool
|
||||
|
||||
fullVolumeToDeleteName := fmt.Sprintf("%s_%s", app.StackName(), volumeToDelete)
|
||||
for _, volName := range volumeNames {
|
||||
if volName == fullVolumeToDeleteName {
|
||||
exactMatch = true
|
||||
}
|
||||
}
|
||||
|
||||
if !exactMatch {
|
||||
log.Fatalf("unable to remove volume: no volume with name '%s'?", volumeToDelete)
|
||||
}
|
||||
|
||||
err := client.RemoveVolumes(cl, context.Background(), []string{fullVolumeToDeleteName}, internal.Force, 5)
|
||||
if err != nil {
|
||||
log.Fatalf("removing volume %s failed: %s", volumeToDelete, err)
|
||||
}
|
||||
|
||||
log.Infof("volume %s removed successfully", volumeToDelete)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var volumesToRemove []string
|
||||
if !internal.Force {
|
||||
if !internal.Force && !internal.NoInput {
|
||||
volumesPrompt := &survey.MultiSelect{
|
||||
Message: "which volumes do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
@ -83,31 +164,39 @@ Passing "--force" will select all volumes for removal. Be careful.
|
||||
Default: volumeNames,
|
||||
}
|
||||
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if internal.Force || internal.NoInput {
|
||||
volumesToRemove = volumeNames
|
||||
}
|
||||
|
||||
err = client.RemoveVolumes(c.Context, app.Server, volumesToRemove, internal.Force)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
if len(volumesToRemove) > 0 {
|
||||
err := client.RemoveVolumes(cl, context.Background(), volumesToRemove, internal.Force, 5)
|
||||
if err != nil {
|
||||
log.Fatalf("removing volumes failed: %s", err)
|
||||
}
|
||||
|
||||
log.Infof("%d volumes removed successfully", len(volumesToRemove))
|
||||
} else {
|
||||
log.Info("no volumes removed")
|
||||
}
|
||||
|
||||
logrus.Info("volumes removed successfully")
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
var appVolumeCommand = &cli.Command{
|
||||
Name: "volume",
|
||||
Aliases: []string{"vl"},
|
||||
Usage: "Manage app volumes",
|
||||
ArgsUsage: "<command>",
|
||||
Subcommands: []*cli.Command{
|
||||
appVolumeListCommand,
|
||||
appVolumeRemoveCommand,
|
||||
},
|
||||
}
|
||||
|
||||
var AppVolumeCommand = &cobra.Command{
|
||||
Use: "volume [cmd] [args] [flags]",
|
||||
Aliases: []string{"vl"},
|
||||
Short: "Manage app volumes",
|
||||
}
|
||||
|
||||
func init() {
|
||||
AppVolumeRemoveCommand.Flags().BoolVarP(
|
||||
&internal.Force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"perform action without further prompt",
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,147 +5,110 @@ import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path"
|
||||
"slices"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/limit"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CatalogueSkipList is all the repos that are not recipes.
|
||||
var CatalogueSkipList = map[string]bool{
|
||||
"abra": true,
|
||||
"abra-apps": true,
|
||||
"abra-aur": true,
|
||||
"abra-bash": true,
|
||||
"abra-capsul": true,
|
||||
"abra-gandi": true,
|
||||
"abra-hetzner": true,
|
||||
"apps": true,
|
||||
"aur-abra-git": true,
|
||||
"auto-apps-json": true,
|
||||
"auto-mirror": true,
|
||||
"backup-bot": true,
|
||||
"backup-bot-two": true,
|
||||
"comrade-renovate-bot": true,
|
||||
"coopcloud.tech": true,
|
||||
"coturn": true,
|
||||
"docker-cp-deploy": true,
|
||||
"docker-dind-bats-kcov": true,
|
||||
"docs.coopcloud.tech": true,
|
||||
"drone-abra": true,
|
||||
"example": true,
|
||||
"gardening": true,
|
||||
"go-abra": true,
|
||||
"organising": true,
|
||||
"outline-with-patch": true,
|
||||
"pyabra": true,
|
||||
"radicle-seed-node": true,
|
||||
"recipes": true,
|
||||
"stack-ssh-deploy": true,
|
||||
"swarm-cronjob": true,
|
||||
"tagcmp": true,
|
||||
"traefik-cert-dumper": true,
|
||||
"tyop": true,
|
||||
}
|
||||
|
||||
var catalogueGenerateCommand = &cli.Command{
|
||||
Name: "generate",
|
||||
var CatalogueGenerateCommand = &cobra.Command{
|
||||
Use: "generate [recipe] [flags]",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate the recipe catalogue",
|
||||
Flags: []cli.Flag{
|
||||
internal.PublishFlag,
|
||||
internal.DryFlag,
|
||||
internal.SkipUpdatesFlag,
|
||||
internal.RegistryUsernameFlag,
|
||||
internal.RegistryPasswordFlag,
|
||||
},
|
||||
Description: `
|
||||
This command generates a new copy of the recipe catalogue which can be found on:
|
||||
Short: "Generate the recipe catalogue",
|
||||
Long: `Generate a new copy of the recipe catalogue.
|
||||
|
||||
https://recipes.coopcloud.tech
|
||||
|
||||
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
|
||||
listing, parses README.md and git tags of those repositories to produce recipe
|
||||
metadata and produces a recipes JSON file.
|
||||
N.B. this command **will** wipe local unstaged changes from your local recipes
|
||||
if present. "--chaos/-C" on this command refers to the catalogue repository
|
||||
("$ABRA_DIR/catalogue") and not the recipes. Please take care not to lose your
|
||||
changes.
|
||||
|
||||
It is possible to generate new metadata for a single recipe by passing
|
||||
<recipe>. The existing local catalogue will be updated, not overwritten.
|
||||
[recipe]. The existing local catalogue will be updated, not overwritten.
|
||||
|
||||
It is quite easy to get rate limited by Docker Hub when running this command.
|
||||
If you have a Hub account you can have Abra log you in to avoid this. Pass
|
||||
"--user" and "--pass".
|
||||
If you have a Hub account you can "docker login" and Abra will automatically
|
||||
use those details.
|
||||
|
||||
Push your new release git.coopcloud.tech with "-p/--publish". This requires
|
||||
Push your new release to git.coopcloud.tech with "--publish/-p". This requires
|
||||
that you have permission to git push to these repositories and have your SSH
|
||||
keys configured on your account.
|
||||
`,
|
||||
ArgsUsage: "[<recipe>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
keys configured on your account.`,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var recipeName string
|
||||
if len(args) > 0 {
|
||||
recipeName = args[0]
|
||||
}
|
||||
|
||||
if recipeName != "" {
|
||||
internal.ValidateRecipe(c)
|
||||
internal.ValidateRecipe(args, cmd.Name())
|
||||
}
|
||||
|
||||
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
|
||||
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
|
||||
if err := gitPkg.Clone(catalogueDir, url); err != nil {
|
||||
return err
|
||||
if err := catalogue.EnsureCatalogue(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
repos, err := recipe.ReadReposMetadata()
|
||||
if !internal.Chaos {
|
||||
if err := catalogue.EnsureIsClean(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repos, err := recipe.ReadReposMetadata(internal.Debug)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var barLength int
|
||||
var logMsg string
|
||||
barLength := len(repos)
|
||||
if recipeName != "" {
|
||||
barLength = 1
|
||||
logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
|
||||
} else {
|
||||
barLength = len(repos)
|
||||
logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
|
||||
}
|
||||
|
||||
if !internal.SkipUpdates {
|
||||
logrus.Warn(logMsg)
|
||||
if err := updateRepositories(repos, recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if !skipUpdates {
|
||||
if err := recipe.UpdateRepositories(repos, recipeName, internal.Debug); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
catl := make(recipe.RecipeCatalogue)
|
||||
catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...")
|
||||
catlBar := formatter.CreateProgressbar(barLength, "collecting catalogue metadata")
|
||||
for _, recipeMeta := range repos {
|
||||
if recipeName != "" && recipeName != recipeMeta.Name {
|
||||
catlBar.Add(1)
|
||||
if !internal.Debug {
|
||||
catlBar.Add(1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := CatalogueSkipList[recipeMeta.Name]; exists {
|
||||
catlBar.Add(1)
|
||||
continue
|
||||
r := recipe.Get(recipeMeta.Name)
|
||||
versions, warnMsgs, err := r.GetRecipeVersions()
|
||||
if err != nil {
|
||||
warnings = append(warnings, err.Error())
|
||||
}
|
||||
if len(warnMsgs) > 0 {
|
||||
warnings = append(warnings, warnMsgs...)
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeVersions(
|
||||
recipeMeta.Name,
|
||||
internal.RegistryUsername,
|
||||
internal.RegistryPassword,
|
||||
)
|
||||
features, category, warnMsgs, err := recipe.GetRecipeFeaturesAndCategory(r)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
warnings = append(warnings, err.Error())
|
||||
}
|
||||
|
||||
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
if len(warnMsgs) > 0 {
|
||||
warnings = append(warnings, warnMsgs...)
|
||||
}
|
||||
|
||||
catl[recipeMeta.Name] = recipe.RecipeMeta{
|
||||
@ -161,163 +124,152 @@ keys configured on your account.
|
||||
Features: features,
|
||||
}
|
||||
|
||||
catlBar.Add(1)
|
||||
if !internal.Debug {
|
||||
catlBar.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := catlBar.Close(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var uniqueWarnings []string
|
||||
for _, w := range warnings {
|
||||
if !slices.Contains(uniqueWarnings, w) {
|
||||
uniqueWarnings = append(uniqueWarnings, w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, warnMsg := range uniqueWarnings {
|
||||
log.Warn(warnMsg)
|
||||
}
|
||||
|
||||
recipesJSON, err := json.MarshalIndent(catl, "", " ")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if recipeName == "" {
|
||||
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
catlFS, err := recipe.ReadRecipeCatalogue()
|
||||
catlFS, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
catlFS[recipeName] = catl[recipeName]
|
||||
|
||||
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
|
||||
log.Infof("generated recipe catalogue: %s", config.RECIPES_JSON)
|
||||
|
||||
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
|
||||
if internal.Publish {
|
||||
if publishChanges {
|
||||
|
||||
isClean, err := gitPkg.IsClean(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if isClean {
|
||||
if !internal.Dry {
|
||||
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
|
||||
log.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
|
||||
}
|
||||
}
|
||||
|
||||
msg := "chore: publish new catalogue release changes"
|
||||
if err := gitPkg.Commit(cataloguePath, "**.json", msg, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := gitPkg.Commit(cataloguePath, msg, internal.Dry); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, "recipes")
|
||||
sshURL := fmt.Sprintf(config.TOOLSHED_SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
|
||||
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !internal.Dry && internal.Publish {
|
||||
url := fmt.Sprintf("%s/recipes/commit/%s", config.REPOS_BASE_URL, head.Hash())
|
||||
logrus.Infof("new changes published: %s", url)
|
||||
if !internal.Dry && publishChanges {
|
||||
url := fmt.Sprintf("%s/%s/commit/%s", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME, head.Hash())
|
||||
log.Infof("new changes published: %s", url)
|
||||
}
|
||||
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changes published")
|
||||
log.Info("dry run: no changes published")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
||||
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
|
||||
var CatalogueCommand = &cli.Command{
|
||||
Name: "catalogue",
|
||||
Usage: "Manage the recipe catalogue",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: "This command helps recipe packagers interact with the recipe catalogue",
|
||||
Subcommands: []*cli.Command{
|
||||
catalogueGenerateCommand,
|
||||
},
|
||||
var CatalogueCommand = &cobra.Command{
|
||||
Use: "catalogue [cmd] [args] [flags]",
|
||||
Short: "Manage the recipe catalogue",
|
||||
Aliases: []string{"c"},
|
||||
}
|
||||
|
||||
func updateRepositories(repos recipe.RepoCatalogue, recipeName string) error {
|
||||
var barLength int
|
||||
if recipeName != "" {
|
||||
barLength = 1
|
||||
} else {
|
||||
barLength = len(repos)
|
||||
}
|
||||
var (
|
||||
publishChanges bool
|
||||
skipUpdates bool
|
||||
)
|
||||
|
||||
cloneLimiter := limit.New(10)
|
||||
func init() {
|
||||
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||
&publishChanges,
|
||||
"publish",
|
||||
"p",
|
||||
false,
|
||||
"publish changes to git.coopcloud.tech",
|
||||
)
|
||||
|
||||
retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
|
||||
ch := make(chan string, barLength)
|
||||
for _, repoMeta := range repos {
|
||||
go func(rm recipe.RepoMeta) {
|
||||
cloneLimiter.Begin()
|
||||
defer cloneLimiter.End()
|
||||
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||
&internal.Dry,
|
||||
"dry-run",
|
||||
"r",
|
||||
false,
|
||||
"report changes that would be made",
|
||||
)
|
||||
|
||||
if recipeName != "" && recipeName != rm.Name {
|
||||
ch <- rm.Name
|
||||
retrieveBar.Add(1)
|
||||
return
|
||||
}
|
||||
if _, exists := CatalogueSkipList[rm.Name]; exists {
|
||||
ch <- rm.Name
|
||||
retrieveBar.Add(1)
|
||||
return
|
||||
}
|
||||
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||
&skipUpdates,
|
||||
"skip-updates",
|
||||
"s",
|
||||
false,
|
||||
"skip updating recipe repositories",
|
||||
)
|
||||
|
||||
recipeDir := path.Join(config.RECIPES_DIR, rm.Name)
|
||||
|
||||
if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipeDir)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isClean {
|
||||
logrus.Fatalf("%s has locally unstaged changes", rm.Name)
|
||||
}
|
||||
|
||||
if err := recipe.EnsureUpToDate(rm.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
ch <- rm.Name
|
||||
retrieveBar.Add(1)
|
||||
}(repoMeta)
|
||||
}
|
||||
|
||||
for range repos {
|
||||
<-ch // wait for everything
|
||||
}
|
||||
|
||||
return nil
|
||||
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
}
|
||||
|
||||
218
cli/cli.go
218
cli/cli.go
@ -1,218 +0,0 @@
|
||||
// Package cli provides the interface for the command-line.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/cli/app"
|
||||
"coopcloud.tech/abra/cli/catalogue"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/cli/recipe"
|
||||
"coopcloud.tech/abra/cli/record"
|
||||
"coopcloud.tech/abra/cli/server"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/web"
|
||||
logrusStack "github.com/Gurpartap/logrus-stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// AutoCompleteCommand helps people set up auto-complete in their shells
|
||||
var AutoCompleteCommand = &cli.Command{
|
||||
Name: "autocomplete",
|
||||
Usage: "Configure shell autocompletion (recommended)",
|
||||
Aliases: []string{"ac"},
|
||||
Description: `
|
||||
This command helps set up autocompletion in your shell by downloading the
|
||||
relevant autocompletion files and laying out what additional information must
|
||||
be loaded.
|
||||
|
||||
Example:
|
||||
|
||||
abra autocomplete bash
|
||||
|
||||
Supported shells are as follows:
|
||||
|
||||
fizsh
|
||||
zsh
|
||||
bash
|
||||
|
||||
`,
|
||||
ArgsUsage: "<shell>",
|
||||
Action: func(c *cli.Context) error {
|
||||
shellType := c.Args().First()
|
||||
|
||||
if shellType == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
|
||||
}
|
||||
|
||||
supportedShells := map[string]bool{
|
||||
"bash": true,
|
||||
"zsh": true,
|
||||
"fizsh": true,
|
||||
}
|
||||
|
||||
if _, ok := supportedShells[shellType]; !ok {
|
||||
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
|
||||
}
|
||||
|
||||
if shellType == "fizsh" {
|
||||
shellType = "zsh" // handled the same on the autocompletion side
|
||||
}
|
||||
|
||||
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
|
||||
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("%s already created", autocompletionDir)
|
||||
}
|
||||
|
||||
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
|
||||
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
|
||||
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
|
||||
logrus.Infof("fetching %s", url)
|
||||
if err := web.GetFile(autocompletionFile, url); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
switch shellType {
|
||||
case "bash":
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
# Run the following commands to install autocompletion
|
||||
sudo mkdir /etc/bash_completion.d/
|
||||
sudo cp %s /etc/bash_completion.d/abra
|
||||
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
|
||||
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
|
||||
`, autocompletionFile))
|
||||
case "zsh":
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
# Run the following commands to install autocompletion
|
||||
sudo mkdir /etc/zsh/completion.d/
|
||||
sudo cp %s /etc/zsh/completion.d/abra
|
||||
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
|
||||
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
|
||||
`, autocompletionFile))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// UpgradeCommand upgrades abra in-place.
|
||||
var UpgradeCommand = &cli.Command{
|
||||
Name: "upgrade",
|
||||
Usage: "Upgrade Abra itself",
|
||||
Aliases: []string{"u"},
|
||||
Description: `
|
||||
This command allows you to upgrade Abra in-place with the latest stable or
|
||||
release candidate.
|
||||
|
||||
If you would like to install the latest release candidate, please pass the
|
||||
"--rc" option. Please bear in mind that the latest release candidate may have
|
||||
some catastrophic bugs contained in it. In any case, thank you very much for
|
||||
the testing efforts!
|
||||
`,
|
||||
Flags: []cli.Flag{internal.RCFlag},
|
||||
Action: func(c *cli.Context) error {
|
||||
mainURL := "https://install.abra.coopcloud.tech"
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
|
||||
|
||||
if internal.RC {
|
||||
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
|
||||
cmd = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
|
||||
}
|
||||
|
||||
logrus.Debugf("attempting to run %s", cmd)
|
||||
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func newAbraApp(version, commit string) *cli.App {
|
||||
app := &cli.App{
|
||||
Name: "abra",
|
||||
Usage: `The Co-op Cloud command-line utility belt 🎩🐇
|
||||
____ ____ _ _
|
||||
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
|
||||
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
|
||||
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
|
||||
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|
||||
|_|
|
||||
`,
|
||||
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
|
||||
Commands: []*cli.Command{
|
||||
app.AppCommand,
|
||||
server.ServerCommand,
|
||||
recipe.RecipeCommand,
|
||||
catalogue.CatalogueCommand,
|
||||
record.RecordCommand,
|
||||
UpgradeCommand,
|
||||
AutoCompleteCommand,
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Authors: []*cli.Author{
|
||||
// If you're looking at this and you hack on Abra and you're not listed
|
||||
// here, please do add yourself! This is a community project, let's show
|
||||
// some love
|
||||
{Name: "3wordchant"},
|
||||
{Name: "decentral1se"},
|
||||
{Name: "kawaiipunk"},
|
||||
{Name: "knoflook"},
|
||||
{Name: "roxxers"},
|
||||
},
|
||||
}
|
||||
|
||||
app.EnableBashCompletion = true
|
||||
|
||||
app.Before = func(c *cli.Context) error {
|
||||
if internal.Debug {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
logrus.SetFormatter(&logrus.TextFormatter{})
|
||||
logrus.SetOutput(os.Stderr)
|
||||
logrus.AddHook(logrusStack.StandardHook())
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
config.ABRA_DIR,
|
||||
path.Join(config.SERVERS_DIR),
|
||||
path.Join(config.RECIPES_DIR),
|
||||
path.Join(config.VENDOR_DIR),
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if err := os.Mkdir(path, 0764); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("abra version %s, commit %s", version, commit)
|
||||
|
||||
return nil
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// RunApp runs CLI abra app.
|
||||
func RunApp(version, commit string) {
|
||||
app := newAbraApp(version, commit)
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
62
cli/complete.go
Normal file
62
cli/complete.go
Normal file
@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var AutocompleteCommand = &cobra.Command{
|
||||
Use: "autocomplete [bash|zsh|fish|powershell]",
|
||||
Short: "Generate autocompletion script",
|
||||
Long: `To load completions:
|
||||
|
||||
Bash:
|
||||
# Load autocompletion for the current Bash session
|
||||
$ source <(abra autocomplete bash)
|
||||
|
||||
# To load autocompletion for each session, execute once:
|
||||
# Linux:
|
||||
$ abra autocomplete bash | sudo tee /etc/bash_completion.d/abra
|
||||
# macOS:
|
||||
$ abra autocomplete bash | sudo tee $(brew --prefix)/etc/bash_completion.d/abra
|
||||
|
||||
Zsh:
|
||||
# If shell autocompletion is not already enabled in your environment,
|
||||
# you will need to enable it. You can execute the following once:
|
||||
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load autocompletions for each session, execute once:
|
||||
$ abra autocomplete zsh > "${fpath[1]}/_abra"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
fish:
|
||||
$ abra autocomplete fish | source
|
||||
|
||||
# To load autocompletions for each session, execute once:
|
||||
$ abra autocomplete fish > ~/.config/fish/completions/abra.fish
|
||||
|
||||
PowerShell:
|
||||
PS> abra autocomplete powershell | Out-String | Invoke-Expression
|
||||
|
||||
# To load autocompletions for every new session, run:
|
||||
PS> abra autocomplete powershell > abra.ps1
|
||||
# and source this file from your PowerShell profile.`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
75
cli/internal/backup.go
Normal file
75
cli/internal/backup.go
Normal file
@ -0,0 +1,75 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/docker/api/types"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// RetrieveBackupBotContainer gets the deployed backupbot container.
|
||||
func RetrieveBackupBotContainer(cl *dockerClient.Client) (types.Container, error) {
|
||||
ctx := context.Background()
|
||||
chosenService, err := service.GetServiceByLabel(ctx, cl, config.BackupbotLabel, NoInput)
|
||||
if err != nil {
|
||||
return types.Container{}, fmt.Errorf("no backupbot discovered, is it deployed?")
|
||||
}
|
||||
|
||||
log.Debugf("retrieved %s as backup enabled service", chosenService.Spec.Name)
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", chosenService.Spec.Name)
|
||||
targetContainer, err := containerPkg.GetContainer(
|
||||
ctx,
|
||||
cl,
|
||||
filters,
|
||||
NoInput,
|
||||
)
|
||||
if err != nil {
|
||||
return types.Container{}, err
|
||||
}
|
||||
|
||||
return targetContainer, nil
|
||||
}
|
||||
|
||||
// RunBackupCmdRemote runs a backup related command on a remote backupbot container.
|
||||
func RunBackupCmdRemote(
|
||||
cl *dockerClient.Client,
|
||||
backupCmd string,
|
||||
containerID string,
|
||||
execEnv []string) (io.Writer, error) {
|
||||
execBackupListOpts := containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"/usr/bin/backup", "--", backupCmd},
|
||||
Detach: false,
|
||||
Env: execEnv,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
log.Debugf("running backup %s on %s with exec config %v", backupCmd, containerID, execBackupListOpts)
|
||||
|
||||
// FIXME: avoid instantiating a new CLI
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := container.RunExec(dcli, cl, containerID, &execBackupListOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
20
cli/internal/cli.go
Normal file
20
cli/internal/cli.go
Normal file
@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
var (
|
||||
// NOTE(d1): global
|
||||
Debug bool
|
||||
NoInput bool
|
||||
Offline bool
|
||||
IgnoreEnvVersion bool
|
||||
|
||||
// NOTE(d1): sub-command specific
|
||||
Chaos bool
|
||||
DontWaitConverge bool
|
||||
Dry bool
|
||||
Force bool
|
||||
MachineReadable bool
|
||||
Major bool
|
||||
Minor bool
|
||||
NoDomainChecks bool
|
||||
Patch bool
|
||||
)
|
||||
@ -2,10 +2,115 @@ package internal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
)
|
||||
|
||||
// RunCmdRemote executes an abra.sh command in the target service
|
||||
func RunCmdRemote(
|
||||
cl *dockerClient.Client,
|
||||
app appPkg.App,
|
||||
disableTTY bool,
|
||||
abraSh, serviceName, cmdName, cmdArgs, remoteUser string) error {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
|
||||
|
||||
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(targetContainer.ID), app.Server)
|
||||
|
||||
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
||||
content, err := archive.TarWithOptions(abraSh, toTarOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copyOpts := containertypes.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
||||
if err := cl.CopyToContainer(context.Background(), targetContainer.ID, "/tmp", content, copyOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// FIXME: avoid instantiating a new CLI
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shell := "/bin/bash"
|
||||
findShell := []string{"test", "-e", shell}
|
||||
execCreateOpts := containertypes.ExecOptions{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: findShell,
|
||||
Detach: false,
|
||||
Tty: false,
|
||||
}
|
||||
|
||||
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||
log.Infof("%s does not exist for %s, use /bin/sh as fallback", shell, app.Name)
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
var cmd []string
|
||||
if cmdArgs != "" {
|
||||
cmd = []string{shell, "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; STACK_NAME=%s; . /tmp/abra.sh; %s %s", serviceName, app.Name, app.StackName(), cmdName, cmdArgs)}
|
||||
} else {
|
||||
cmd = []string{shell, "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; STACK_NAME=%s; . /tmp/abra.sh; %s", serviceName, app.Name, app.StackName(), cmdName)}
|
||||
}
|
||||
|
||||
log.Debugf("running command: %s", strings.Join(cmd, " "))
|
||||
|
||||
if remoteUser != "" {
|
||||
log.Debugf("running command with user %s", remoteUser)
|
||||
execCreateOpts.User = remoteUser
|
||||
}
|
||||
|
||||
execCreateOpts.Cmd = cmd
|
||||
|
||||
execCreateOpts.Tty = true
|
||||
if disableTTY {
|
||||
execCreateOpts.Tty = false
|
||||
log.Debugf("not requesting a remote TTY")
|
||||
}
|
||||
|
||||
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureCommand(abraSh, recipeName, execCmd string) error {
|
||||
bytes, err := ioutil.ReadFile(abraSh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.Contains(string(bytes), execCmd) {
|
||||
return fmt.Errorf("%s doesn't have a %s function", recipeName, execCmd)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunCmd runs a shell command and streams stdout/stderr in real-time.
|
||||
func RunCmd(cmd *exec.Cmd) error {
|
||||
r, err := cmd.StdoutPipe()
|
||||
|
||||
@ -1,170 +1,58 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DeployAction is the main command-line action for this package
|
||||
func DeployAction(c *cli.Context) error {
|
||||
app := ValidateApp(c)
|
||||
var borderStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Padding(0, 1, 0, 1).
|
||||
MaxWidth(79).
|
||||
BorderForeground(lipgloss.Color("63"))
|
||||
|
||||
if err := recipe.EnsureUpToDate(app.Type); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
var headerStyle = lipgloss.NewStyle().
|
||||
Underline(true).
|
||||
Bold(true).
|
||||
PaddingBottom(1)
|
||||
|
||||
r, err := recipe.Get(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
var leftStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
var rightStyle = lipgloss.NewStyle()
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
// horizontal is a JoinHorizontal helper function.
|
||||
func horizontal(left, mid, right string) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, left, mid, right)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", app.StackName())
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if isDeployed {
|
||||
if Force || Chaos {
|
||||
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name)
|
||||
} else {
|
||||
logrus.Fatalf("%s is already deployed", app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
version := deployedVersion
|
||||
if version == "unknown" && !Chaos {
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if len(versions) > 0 {
|
||||
version = versions[len(versions)-1]
|
||||
logrus.Debugf("choosing %s as version to deploy", version)
|
||||
if err := recipe.EnsureVersion(app.Type, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
head, err := git.GetRecipeHead(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
version = formatter.SmallSHA(head.String())
|
||||
logrus.Warn("no versions detected, using latest commit")
|
||||
if err := recipe.EnsureLatest(app.Type); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version == "unknown" && !Chaos {
|
||||
logrus.Debugf("choosing %s as version to deploy", version)
|
||||
if err := recipe.EnsureVersion(app.Type, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if version != "unknown" && !Chaos {
|
||||
if err := recipe.EnsureVersion(app.Type, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if Chaos {
|
||||
logrus.Warnf("chaos mode engaged")
|
||||
var err error
|
||||
version, err = recipe.ChaosVersion(app.Type)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh")
|
||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
for k, v := range abraShEnv {
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: app.StackName(),
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
}
|
||||
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := DeployOverview(app, version, "continue with deployment?"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !NoDomainChecks {
|
||||
domainName := app.Env["DOMAIN"]
|
||||
ipv4, err := dns.EnsureIPv4(domainName)
|
||||
if err != nil || ipv4 == "" {
|
||||
logrus.Fatalf("could not find an IP address assigned to %s?", domainName)
|
||||
}
|
||||
|
||||
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
logrus.Warn("skipping domain checks as requested")
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, DontWaitConverge); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func formatComposeFiles(composeFiles string) string {
|
||||
return strings.ReplaceAll(composeFiles, ":", "\n")
|
||||
}
|
||||
|
||||
// DeployOverview shows a deployment overview
|
||||
func DeployOverview(app config.App, version, message string) error {
|
||||
tableCol := []string{"server", "compose", "domain", "app name", "version"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
func DeployOverview(
|
||||
app appPkg.App,
|
||||
deployedVersion string,
|
||||
toDeployVersion string,
|
||||
releaseNotes string,
|
||||
warnMessages []string,
|
||||
) error {
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
deployConfig = formatComposeFiles(composeFiles)
|
||||
}
|
||||
|
||||
server := app.Server
|
||||
@ -172,97 +60,213 @@ func DeployOverview(app config.App, version, message string) error {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, deployConfig, app.Domain, app.Name, version})
|
||||
table.Render()
|
||||
domain := app.Domain
|
||||
if domain == "" {
|
||||
domain = config.NO_DOMAIN_DEFAULT
|
||||
}
|
||||
|
||||
envVersion := app.Recipe.EnvVersionRaw
|
||||
if envVersion == "" {
|
||||
envVersion = config.NO_VERSION_DEFAULT
|
||||
}
|
||||
|
||||
rows := [][]string{
|
||||
{"DOMAIN", domain},
|
||||
{"RECIPE", app.Recipe.Name},
|
||||
{"SERVER", server},
|
||||
{"CONFIG", deployConfig},
|
||||
{"", ""},
|
||||
{"CURRENT DEPLOYMENT", formatter.BoldDirtyDefault(deployedVersion)},
|
||||
{"ENV VERSION", formatter.BoldDirtyDefault(envVersion)},
|
||||
{"NEW DEPLOYMENT", formatter.BoldDirtyDefault(toDeployVersion)},
|
||||
}
|
||||
|
||||
deployType := getDeployType(deployedVersion, toDeployVersion)
|
||||
overview := formatter.CreateOverview(fmt.Sprintf("%s OVERVIEW", deployType), rows)
|
||||
|
||||
fmt.Println(overview)
|
||||
|
||||
if releaseNotes != "" {
|
||||
fmt.Print(releaseNotes)
|
||||
}
|
||||
|
||||
for _, msg := range warnMessages {
|
||||
log.Warn(msg)
|
||||
}
|
||||
|
||||
if NoInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: message,
|
||||
}
|
||||
|
||||
prompt := &survey.Confirm{Message: "proceed?"}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
log.Fatal("deployment cancelled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewVersionOverview shows an upgrade or downgrade overview
|
||||
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error {
|
||||
tableCol := []string{"server", "compose", "domain", "app name", "current version", "to be deployed"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
func getDeployType(currentVersion, newVersion string) string {
|
||||
if newVersion == config.NO_DOMAIN_DEFAULT {
|
||||
return "UNDEPLOY"
|
||||
}
|
||||
if strings.Contains(newVersion, "+U") {
|
||||
return "CHAOS DEPLOY"
|
||||
}
|
||||
if strings.Contains(currentVersion, "+U") {
|
||||
return "UNCHAOS DEPLOY"
|
||||
}
|
||||
if currentVersion == newVersion {
|
||||
return "REDEPLOY"
|
||||
}
|
||||
if currentVersion == config.NO_VERSION_DEFAULT {
|
||||
return "NEW DEPLOY"
|
||||
}
|
||||
currentParsed, err := tagcmp.Parse(currentVersion)
|
||||
if err != nil {
|
||||
return "DEPLOY"
|
||||
}
|
||||
newParsed, err := tagcmp.Parse(newVersion)
|
||||
if err != nil {
|
||||
return "DEPLOY"
|
||||
}
|
||||
if currentParsed.IsLessThan(newParsed) {
|
||||
return "UPGRADE"
|
||||
}
|
||||
return "DOWNGRADE"
|
||||
}
|
||||
|
||||
// MoveOverview shows a overview before moving an app to a different server
|
||||
func MoveOverview(
|
||||
app appPkg.App,
|
||||
newServer string,
|
||||
secrets []string,
|
||||
volumes []string,
|
||||
) {
|
||||
server := app.Server
|
||||
if app.Server == "default" {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, deployConfig, app.Domain, app.Name, currentVersion, newVersion})
|
||||
table.Render()
|
||||
domain := app.Domain
|
||||
if domain == "" {
|
||||
domain = config.NO_DOMAIN_DEFAULT
|
||||
}
|
||||
|
||||
if releaseNotes == "" {
|
||||
var err error
|
||||
releaseNotes, err = GetReleaseNotes(app.Type, newVersion)
|
||||
rows := [][]string{
|
||||
{"DOMAIN", domain},
|
||||
{"RECIPE", app.Recipe.Name},
|
||||
{"OLD SERVER", server},
|
||||
{"New SERVER", newServer},
|
||||
{"SECRETS", strings.Join(secrets, "\n")},
|
||||
{"VOLUMES", strings.Join(volumes, "\n")},
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview("MOVE OVERVIEW", rows)
|
||||
|
||||
fmt.Println(overview)
|
||||
}
|
||||
|
||||
func PromptProcced() error {
|
||||
if NoInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
if Dry {
|
||||
return fmt.Errorf("dry run")
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{Message: "proceed?"}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
return errors.New("cancelled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostCmds parses a string of commands and executes them inside of the respective services
|
||||
// the commands string must have the following format:
|
||||
// "<service> <command> <arguments>|<service> <command> <arguments>|... "
|
||||
func PostCmds(cl *dockerClient.Client, app appPkg.App, commands string) error {
|
||||
if _, err := os.Stat(app.Recipe.AbraShPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s does not exist for %s?", app.Recipe.AbraShPath, app.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, command := range strings.Split(commands, "|") {
|
||||
commandParts := strings.Split(command, " ")
|
||||
if len(commandParts) < 2 {
|
||||
return fmt.Errorf("not enough arguments: %s", command)
|
||||
}
|
||||
targetServiceName := commandParts[0]
|
||||
cmdName := commandParts[1]
|
||||
parsedCmdArgs := ""
|
||||
if len(commandParts) > 2 {
|
||||
parsedCmdArgs = fmt.Sprintf("%s ", strings.Join(commandParts[2:], " "))
|
||||
}
|
||||
log.Infof("running post-command '%s %s' in container %s", cmdName, parsedCmdArgs, targetServiceName)
|
||||
|
||||
if err := EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if releaseNotes != "" && newVersion != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(fmt.Sprintf("%s release notes:\n\n%s", newVersion, releaseNotes))
|
||||
} else {
|
||||
logrus.Warnf("no release notes available for %s", newVersion)
|
||||
}
|
||||
matchingServiceName := false
|
||||
for _, serviceName := range serviceNames {
|
||||
if serviceName == targetServiceName {
|
||||
matchingServiceName = true
|
||||
}
|
||||
}
|
||||
|
||||
if NoInput {
|
||||
return nil
|
||||
}
|
||||
if !matchingServiceName {
|
||||
return fmt.Errorf("no service %s for %s?", targetServiceName, app.Name)
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with deployment?",
|
||||
}
|
||||
log.Debugf("running command %s %s within the context of %s_%s", cmdName, parsedCmdArgs, app.StackName(), targetServiceName)
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
requestTTY := true
|
||||
if err := RunCmdRemote(
|
||||
cl,
|
||||
app,
|
||||
requestTTY,
|
||||
app.Recipe.AbraShPath, targetServiceName, cmdName, parsedCmdArgs, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReleaseNotes prints release notes for a recipe version
|
||||
func GetReleaseNotes(recipeName, version string) (string, error) {
|
||||
if version == "" {
|
||||
return "", nil
|
||||
// SortVersionsDesc sorts versions in descending order.
|
||||
func SortVersionsDesc(versions []string) []string {
|
||||
var tags []tagcmp.Tag
|
||||
|
||||
for _, v := range versions {
|
||||
parsed, _ := tagcmp.Parse(v) // skips unsupported tags
|
||||
tags = append(tags, parsed)
|
||||
}
|
||||
|
||||
fpath := path.Join(config.RECIPES_DIR, recipeName, "release", version)
|
||||
sort.Sort(tagcmp.ByTagDesc(tags))
|
||||
|
||||
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
|
||||
releaseNotes, err := ioutil.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(releaseNotes), nil
|
||||
var desc []string
|
||||
for _, t := range tags {
|
||||
desc = append(desc, t.String())
|
||||
}
|
||||
|
||||
return "", nil
|
||||
return desc
|
||||
}
|
||||
|
||||
17
cli/internal/deploy_test.go
Normal file
17
cli/internal/deploy_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSortVersionsDesc(t *testing.T) {
|
||||
versions := SortVersionsDesc([]string{
|
||||
"0.2.3+1.2.2",
|
||||
"1.0.0+2.2.2",
|
||||
})
|
||||
|
||||
assert.Equal(t, "1.0.0+2.2.2", versions[0])
|
||||
assert.Equal(t, "0.2.3+1.2.2", versions[1])
|
||||
}
|
||||
11
cli/internal/ensure.go
Normal file
11
cli/internal/ensure.go
Normal file
@ -0,0 +1,11 @@
|
||||
package internal
|
||||
|
||||
import "coopcloud.tech/abra/pkg/recipe"
|
||||
|
||||
func GetEnsureContext() recipe.EnsureContext {
|
||||
return recipe.EnsureContext{
|
||||
Chaos,
|
||||
Offline,
|
||||
IgnoreEnvVersion,
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// ShowSubcommandHelpAndError exits the program on error, logs the error to the
|
||||
// terminal, and shows the help command.
|
||||
func ShowSubcommandHelpAndError(c *cli.Context, err interface{}) {
|
||||
if err2 := cli.ShowSubcommandHelp(c); err2 != nil {
|
||||
logrus.Error(err2)
|
||||
}
|
||||
logrus.Error(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@ -1,488 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Secrets stores the variable from SecretsFlag
|
||||
var Secrets bool
|
||||
|
||||
// SecretsFlag turns on/off automatically generating secrets
|
||||
var SecretsFlag = &cli.BoolFlag{
|
||||
Name: "secrets",
|
||||
Aliases: []string{"ss"},
|
||||
Value: false,
|
||||
Usage: "Automatically generate secrets",
|
||||
Destination: &Secrets,
|
||||
}
|
||||
|
||||
// Pass stores the variable from PassFlag
|
||||
var Pass bool
|
||||
|
||||
// PassFlag turns on/off storing generated secrets in pass
|
||||
var PassFlag = &cli.BoolFlag{
|
||||
Name: "pass",
|
||||
Aliases: []string{"p"},
|
||||
Value: false,
|
||||
Usage: "Store the generated secrets in a local pass store",
|
||||
Destination: &Pass,
|
||||
}
|
||||
|
||||
// Context is temp
|
||||
var Context string
|
||||
|
||||
// ContextFlag is temp
|
||||
var ContextFlag = &cli.StringFlag{
|
||||
Name: "context",
|
||||
Value: "",
|
||||
Aliases: []string{"c"},
|
||||
Destination: &Context,
|
||||
}
|
||||
|
||||
// Force force functionality without asking.
|
||||
var Force bool
|
||||
|
||||
// ForceFlag turns on/off force functionality.
|
||||
var ForceFlag = &cli.BoolFlag{
|
||||
Name: "force",
|
||||
Value: false,
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Perform action without further prompt. Use with care!",
|
||||
Destination: &Force,
|
||||
}
|
||||
|
||||
// Chaos engages chaos mode.
|
||||
var Chaos bool
|
||||
|
||||
// ChaosFlag turns on/off chaos functionality.
|
||||
var ChaosFlag = &cli.BoolFlag{
|
||||
Name: "chaos",
|
||||
Value: false,
|
||||
Aliases: []string{"ch"},
|
||||
Usage: "Deploy uncommitted recipes changes. Use with care!",
|
||||
Destination: &Chaos,
|
||||
}
|
||||
|
||||
// DNSProvider specifies a DNS provider.
|
||||
var DNSProvider string
|
||||
|
||||
// DNSProviderFlag selects a DNS provider.
|
||||
var DNSProviderFlag = &cli.StringFlag{
|
||||
Name: "provider",
|
||||
Value: "",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "DNS provider",
|
||||
Destination: &DNSProvider,
|
||||
}
|
||||
|
||||
var NoInput bool
|
||||
var NoInputFlag = &cli.BoolFlag{
|
||||
Name: "no-input",
|
||||
Value: false,
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Toggle non-interactive mode",
|
||||
Destination: &NoInput,
|
||||
}
|
||||
|
||||
var DNSType string
|
||||
|
||||
var DNSTypeFlag = &cli.StringFlag{
|
||||
Name: "type",
|
||||
Value: "",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Domain name record type (e.g. A)",
|
||||
Destination: &DNSType,
|
||||
}
|
||||
|
||||
var DNSName string
|
||||
|
||||
var DNSNameFlag = &cli.StringFlag{
|
||||
Name: "name",
|
||||
Value: "",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Domain name record name (e.g. mysubdomain)",
|
||||
Destination: &DNSName,
|
||||
}
|
||||
|
||||
var DNSValue string
|
||||
|
||||
var DNSValueFlag = &cli.StringFlag{
|
||||
Name: "value",
|
||||
Value: "",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Domain name record value (e.g. 192.168.1.1)",
|
||||
Destination: &DNSValue,
|
||||
}
|
||||
|
||||
var DNSTTL string
|
||||
var DNSTTLFlag = &cli.StringFlag{
|
||||
Name: "ttl",
|
||||
Value: "600s",
|
||||
Aliases: []string{"T"},
|
||||
Usage: "Domain name TTL value (seconds)",
|
||||
Destination: &DNSTTL,
|
||||
}
|
||||
|
||||
var DNSPriority int
|
||||
|
||||
var DNSPriorityFlag = &cli.IntFlag{
|
||||
Name: "priority",
|
||||
Value: 10,
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Domain name priority value",
|
||||
Destination: &DNSPriority,
|
||||
}
|
||||
|
||||
var ServerProvider string
|
||||
|
||||
var ServerProviderFlag = &cli.StringFlag{
|
||||
Name: "provider",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "3rd party server provider",
|
||||
Destination: &ServerProvider,
|
||||
}
|
||||
|
||||
var CapsulInstanceURL string
|
||||
|
||||
var CapsulInstanceURLFlag = &cli.StringFlag{
|
||||
Name: "capsul-url",
|
||||
Value: "yolo.servers.coop",
|
||||
Aliases: []string{"cu"},
|
||||
Usage: "capsul instance URL",
|
||||
Destination: &CapsulInstanceURL,
|
||||
}
|
||||
|
||||
var CapsulName string
|
||||
|
||||
var CapsulNameFlag = &cli.StringFlag{
|
||||
Name: "capsul-name",
|
||||
Value: "",
|
||||
Aliases: []string{"cn"},
|
||||
Usage: "capsul name",
|
||||
Destination: &CapsulName,
|
||||
}
|
||||
|
||||
var CapsulType string
|
||||
|
||||
var CapsulTypeFlag = &cli.StringFlag{
|
||||
Name: "capsul-type",
|
||||
Value: "f1-xs",
|
||||
Aliases: []string{"ct"},
|
||||
Usage: "capsul type",
|
||||
Destination: &CapsulType,
|
||||
}
|
||||
|
||||
var CapsulImage string
|
||||
|
||||
var CapsulImageFlag = &cli.StringFlag{
|
||||
Name: "capsul-image",
|
||||
Value: "debian10",
|
||||
Aliases: []string{"ci"},
|
||||
Usage: "capsul image",
|
||||
Destination: &CapsulImage,
|
||||
}
|
||||
|
||||
var CapsulSSHKeys cli.StringSlice
|
||||
|
||||
var CapsulSSHKeysFlag = &cli.StringSliceFlag{
|
||||
Name: "capsul-ssh-keys",
|
||||
Aliases: []string{"cs"},
|
||||
Usage: "capsul SSH key",
|
||||
Destination: &CapsulSSHKeys,
|
||||
}
|
||||
|
||||
var CapsulAPIToken string
|
||||
|
||||
var CapsulAPITokenFlag = &cli.StringFlag{
|
||||
Name: "capsul-token",
|
||||
Aliases: []string{"ca"},
|
||||
Usage: "capsul API token",
|
||||
EnvVars: []string{"CAPSUL_TOKEN"},
|
||||
Destination: &CapsulAPIToken,
|
||||
}
|
||||
|
||||
var HetznerCloudName string
|
||||
|
||||
var HetznerCloudNameFlag = &cli.StringFlag{
|
||||
Name: "hetzner-name",
|
||||
Value: "",
|
||||
Aliases: []string{"hn"},
|
||||
Usage: "hetzner cloud name",
|
||||
Destination: &HetznerCloudName,
|
||||
}
|
||||
|
||||
var HetznerCloudType string
|
||||
|
||||
var HetznerCloudTypeFlag = &cli.StringFlag{
|
||||
Name: "hetzner-type",
|
||||
Aliases: []string{"ht"},
|
||||
Usage: "hetzner cloud type",
|
||||
Destination: &HetznerCloudType,
|
||||
Value: "cx11",
|
||||
}
|
||||
|
||||
var HetznerCloudImage string
|
||||
|
||||
var HetznerCloudImageFlag = &cli.StringFlag{
|
||||
Name: "hetzner-image",
|
||||
Aliases: []string{"hi"},
|
||||
Usage: "hetzner cloud image",
|
||||
Value: "debian-10",
|
||||
Destination: &HetznerCloudImage,
|
||||
}
|
||||
|
||||
var HetznerCloudSSHKeys cli.StringSlice
|
||||
|
||||
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
|
||||
Name: "hetzner-ssh-keys",
|
||||
Aliases: []string{"hs"},
|
||||
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
|
||||
Destination: &HetznerCloudSSHKeys,
|
||||
}
|
||||
|
||||
var HetznerCloudLocation string
|
||||
|
||||
var HetznerCloudLocationFlag = &cli.StringFlag{
|
||||
Name: "hetzner-location",
|
||||
Aliases: []string{"hl"},
|
||||
Usage: "hetzner cloud server location",
|
||||
Value: "hel1",
|
||||
Destination: &HetznerCloudLocation,
|
||||
}
|
||||
|
||||
var HetznerCloudAPIToken string
|
||||
|
||||
var HetznerCloudAPITokenFlag = &cli.StringFlag{
|
||||
Name: "hetzner-token",
|
||||
Aliases: []string{"ha"},
|
||||
Usage: "hetzner cloud API token",
|
||||
EnvVars: []string{"HCLOUD_TOKEN"},
|
||||
Destination: &HetznerCloudAPIToken,
|
||||
}
|
||||
|
||||
// Debug stores the variable from DebugFlag.
|
||||
var Debug bool
|
||||
|
||||
// DebugFlag turns on/off verbose logging down to the DEBUG level.
|
||||
var DebugFlag = &cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Aliases: []string{"d"},
|
||||
Value: false,
|
||||
Destination: &Debug,
|
||||
Usage: "Show DEBUG messages",
|
||||
}
|
||||
|
||||
// RC signifies the latest release candidate
|
||||
var RC bool
|
||||
|
||||
// RCFlag chooses the latest release candidate for install
|
||||
var RCFlag = &cli.BoolFlag{
|
||||
Name: "rc",
|
||||
Value: false,
|
||||
Destination: &RC,
|
||||
Usage: "Insatll the latest release candidate",
|
||||
}
|
||||
|
||||
var Major bool
|
||||
var MajorFlag = &cli.BoolFlag{
|
||||
Name: "major",
|
||||
Usage: "Increase the major part of the version",
|
||||
Value: false,
|
||||
Aliases: []string{"ma", "x"},
|
||||
Destination: &Major,
|
||||
}
|
||||
|
||||
var Minor bool
|
||||
var MinorFlag = &cli.BoolFlag{
|
||||
Name: "minor",
|
||||
Usage: "Increase the minor part of the version",
|
||||
Value: false,
|
||||
Aliases: []string{"mi", "y"},
|
||||
Destination: &Minor,
|
||||
}
|
||||
|
||||
var Patch bool
|
||||
var PatchFlag = &cli.BoolFlag{
|
||||
Name: "patch",
|
||||
Usage: "Increase the patch part of the version",
|
||||
Value: false,
|
||||
Aliases: []string{"pa", "z"},
|
||||
Destination: &Patch,
|
||||
}
|
||||
|
||||
var Dry bool
|
||||
var DryFlag = &cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
Usage: "Only reports changes that would be made",
|
||||
Value: false,
|
||||
Aliases: []string{"d"},
|
||||
Destination: &Dry,
|
||||
}
|
||||
|
||||
var Publish bool
|
||||
var PublishFlag = &cli.BoolFlag{
|
||||
Name: "publish",
|
||||
Usage: "Publish changes to git.coopcloud.tech",
|
||||
Value: false,
|
||||
Aliases: []string{"p"},
|
||||
Destination: &Publish,
|
||||
}
|
||||
|
||||
var Domain string
|
||||
var DomainFlag = &cli.StringFlag{
|
||||
Name: "domain",
|
||||
Aliases: []string{"d"},
|
||||
Value: "",
|
||||
Usage: "Choose a domain name",
|
||||
Destination: &Domain,
|
||||
}
|
||||
|
||||
var NewAppServer string
|
||||
var NewAppServerFlag = &cli.StringFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific server",
|
||||
Destination: &NewAppServer,
|
||||
}
|
||||
|
||||
var NewAppName string
|
||||
var NewAppNameFlag = &cli.StringFlag{
|
||||
Name: "app-name",
|
||||
Aliases: []string{"a"},
|
||||
Value: "",
|
||||
Usage: "Choose an app name",
|
||||
Destination: &NewAppName,
|
||||
}
|
||||
|
||||
var NoDomainChecks bool
|
||||
var NoDomainChecksFlag = &cli.BoolFlag{
|
||||
Name: "no-domain-checks",
|
||||
Aliases: []string{"nd"},
|
||||
Value: false,
|
||||
Usage: "Disable app domain sanity checks",
|
||||
Destination: &NoDomainChecks,
|
||||
}
|
||||
|
||||
var StdErrOnly bool
|
||||
var StdErrOnlyFlag = &cli.BoolFlag{
|
||||
Name: "stderr",
|
||||
Aliases: []string{"s"},
|
||||
Value: false,
|
||||
Usage: "Only tail stderr",
|
||||
Destination: &StdErrOnly,
|
||||
}
|
||||
|
||||
var AutoDNSRecord bool
|
||||
var AutoDNSRecordFlag = &cli.BoolFlag{
|
||||
Name: "auto",
|
||||
Aliases: []string{"a"},
|
||||
Value: false,
|
||||
Usage: "Automatically configure DNS records",
|
||||
Destination: &AutoDNSRecord,
|
||||
}
|
||||
|
||||
var DontWaitConverge bool
|
||||
var DontWaitConvergeFlag = &cli.BoolFlag{
|
||||
Name: "no-converge-checks",
|
||||
Aliases: []string{"nc"},
|
||||
Value: false,
|
||||
Usage: "Don't wait for converge logic checks",
|
||||
Destination: &DontWaitConverge,
|
||||
}
|
||||
|
||||
var Watch bool
|
||||
var WatchFlag = &cli.BoolFlag{
|
||||
Name: "watch",
|
||||
Aliases: []string{"w"},
|
||||
Value: false,
|
||||
Usage: "Watch status by polling repeatedly",
|
||||
Destination: &Watch,
|
||||
}
|
||||
|
||||
var OnlyErrors bool
|
||||
var OnlyErrorFlag = &cli.BoolFlag{
|
||||
Name: "errors",
|
||||
Aliases: []string{"e"},
|
||||
Value: false,
|
||||
Usage: "Only show errors",
|
||||
Destination: &OnlyErrors,
|
||||
}
|
||||
|
||||
var SkipUpdates bool
|
||||
var SkipUpdatesFlag = &cli.BoolFlag{
|
||||
Name: "skip-updates",
|
||||
Aliases: []string{"s"},
|
||||
Value: false,
|
||||
Usage: "Skip updating recipe repositories",
|
||||
Destination: &SkipUpdates,
|
||||
}
|
||||
|
||||
var RegistryUsername string
|
||||
var RegistryUsernameFlag = &cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"user"},
|
||||
Value: "",
|
||||
Usage: "Registry username",
|
||||
EnvVars: []string{"REGISTRY_USERNAME"},
|
||||
Destination: &RegistryUsername,
|
||||
}
|
||||
|
||||
var RegistryPassword string
|
||||
var RegistryPasswordFlag = &cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pass"},
|
||||
Value: "",
|
||||
Usage: "Registry password",
|
||||
EnvVars: []string{"REGISTRY_PASSWORD"},
|
||||
Destination: &RegistryUsername,
|
||||
}
|
||||
|
||||
// SSHFailMsg is a hopefully helpful SSH failure message
|
||||
var SSHFailMsg = `
|
||||
Woops, Abra is unable to connect to connect to %s.
|
||||
|
||||
Here are a few tips for debugging your local SSH config. Abra uses plain 'ol
|
||||
SSH to make connections to servers, so if your SSH config is working, Abra is
|
||||
working.
|
||||
|
||||
In the first place, Abra will always try to read your Docker context connection
|
||||
string for SSH connection details. You can view your server context configs
|
||||
with the following command. Are they correct?
|
||||
|
||||
abra server ls
|
||||
|
||||
Is your ssh-agent running? You can start it by running the following command:
|
||||
|
||||
eval "$(ssh-agent)"
|
||||
|
||||
If your SSH private key loaded? You can check by running the following command:
|
||||
|
||||
ssh-add -L
|
||||
|
||||
If, you can add it with:
|
||||
|
||||
ssh-add ~/.ssh/<private-key-part>
|
||||
|
||||
If you are using a non-default public/private key, you can configure this in
|
||||
your ~/.ssh/config file which Abra will read in order to figure out connection
|
||||
details:
|
||||
|
||||
Host foo.coopcloud.tech
|
||||
Hostname foo.coopcloud.tech
|
||||
User bar
|
||||
Port 12345
|
||||
IdentityFile ~/.ssh/bar@foo.coopcloud.tech
|
||||
|
||||
If you're only using password authentication, you can use the following config:
|
||||
|
||||
Host foo.coopcloud.tech
|
||||
Hostname foo.coopcloud.tech
|
||||
User bar
|
||||
Port 12345
|
||||
PreferredAuthentications=password
|
||||
PubkeyAuthentication=no
|
||||
|
||||
Good luck!
|
||||
|
||||
`
|
||||
@ -1,10 +0,0 @@
|
||||
package internal
|
||||
|
||||
// ReverseStringList reverses a list of a strings. Roll on Go generics.
|
||||
func ReverseStringList(strings []string) []string {
|
||||
for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 {
|
||||
strings[i], strings[j] = strings[j], strings[i]
|
||||
}
|
||||
|
||||
return strings
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// AppSecrets represents all app secrest
|
||||
type AppSecrets map[string]string
|
||||
|
||||
// RecipeName is used for configuring recipe name programmatically
|
||||
var RecipeName string
|
||||
|
||||
// createSecrets creates all secrets for a new app.
|
||||
func createSecrets(sanitisedAppName string) (AppSecrets, error) {
|
||||
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
|
||||
appEnv, err := config.ReadEnv(appEnvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretEnvVars := secret.ReadSecretEnvVars(appEnv)
|
||||
secrets, err := secret.GenerateSecrets(secretEnvVars, sanitisedAppName, NewAppServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if Pass {
|
||||
for secretName := range secrets {
|
||||
secretValue := secrets[secretName]
|
||||
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, NewAppServer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
|
||||
func ensureDomainFlag(recipe recipe.Recipe, server string) error {
|
||||
if Domain == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify app domain",
|
||||
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &Domain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if Domain == "" {
|
||||
return fmt.Errorf("no domain provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
|
||||
func ensureServerFlag() error {
|
||||
servers, err := config.GetServers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if NewAppServer == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select app server:",
|
||||
Options: servers,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &NewAppServer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if NewAppServer == "" {
|
||||
return fmt.Errorf("no server provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
|
||||
func ensureAppNameFlag() error {
|
||||
if NewAppName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify app name:",
|
||||
Default: Domain,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &NewAppName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if NewAppName == "" {
|
||||
return fmt.Errorf("no app name provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAction is the new app creation logic
|
||||
func NewAction(c *cli.Context) error {
|
||||
recipe := ValidateRecipeWithPrompt(c)
|
||||
|
||||
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureServerFlag(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDomainFlag(recipe, NewAppServer); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureAppNameFlag(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
sanitisedAppName := config.SanitiseAppName(NewAppName)
|
||||
if len(sanitisedAppName) > 45 {
|
||||
logrus.Fatalf("%s cannot be longer than 45 characters", sanitisedAppName)
|
||||
}
|
||||
logrus.Debugf("%s sanitised as %s for new app", NewAppName, sanitisedAppName)
|
||||
|
||||
if err := config.TemplateAppEnvSample(recipe.Name, NewAppName, NewAppServer, Domain); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if Secrets {
|
||||
if err := ssh.EnsureHostKey(NewAppServer); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secrets, err := createSecrets(sanitisedAppName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secretCols := []string{"Name", "Value"}
|
||||
secretTable := formatter.CreateTable(secretCols)
|
||||
for secret := range secrets {
|
||||
secretTable.Append([]string{secret, secrets[secret]})
|
||||
}
|
||||
|
||||
if len(secrets) > 0 {
|
||||
defer secretTable.Render()
|
||||
}
|
||||
}
|
||||
|
||||
if NewAppServer == "default" {
|
||||
NewAppServer = "local"
|
||||
}
|
||||
|
||||
tableCol := []string{"Name", "Domain", "Type", "Server"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
|
||||
|
||||
fmt.Println("")
|
||||
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
|
||||
fmt.Println("")
|
||||
table.Render()
|
||||
fmt.Println("")
|
||||
fmt.Println("You can configure this app by running the following:")
|
||||
fmt.Println(fmt.Sprintf("\n abra app config %s", sanitisedAppName))
|
||||
fmt.Println("")
|
||||
fmt.Println("You can deploy this app by running the following:")
|
||||
fmt.Println(fmt.Sprintf("\n abra app deploy %s", sanitisedAppName))
|
||||
fmt.Println("")
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -2,16 +2,16 @@ package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/distribution/reference"
|
||||
)
|
||||
|
||||
// PromptBumpType prompts for version bump type
|
||||
func PromptBumpType(tagString string) error {
|
||||
func PromptBumpType(tagString, latestRelease string) error {
|
||||
if (!Major && !Minor && !Patch) && tagString == "" {
|
||||
fmt.Printf(`
|
||||
You need to make a decision about what kind of an update this new recipe
|
||||
@ -20,6 +20,8 @@ migration work or take care of some breaking changes? This can be signaled in
|
||||
the version you specify on the recipe deploy label and is called a semantic
|
||||
version.
|
||||
|
||||
The latest published version is %s.
|
||||
|
||||
Here is a semver cheat sheet (more on https://semver.org):
|
||||
|
||||
major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).
|
||||
@ -34,7 +36,7 @@ Here is a semver cheat sheet (more on https://semver.org):
|
||||
should also Just Work and is mostly to do with minor bug fixes
|
||||
and/or security patches. "nothing to worry about".
|
||||
|
||||
`)
|
||||
`, latestRelease)
|
||||
|
||||
var chosenBumpType string
|
||||
prompt := &survey.Select{
|
||||
@ -63,7 +65,7 @@ func GetBumpType() string {
|
||||
} else if Patch {
|
||||
bumpType = "patch"
|
||||
} else {
|
||||
logrus.Fatal("no version bump type specififed?")
|
||||
log.Fatal("no version bump type specififed?")
|
||||
}
|
||||
|
||||
return bumpType
|
||||
@ -78,7 +80,7 @@ func SetBumpType(bumpType string) {
|
||||
} else if bumpType == "patch" {
|
||||
Patch = true
|
||||
} else {
|
||||
logrus.Fatal("no version bump type specififed?")
|
||||
log.Fatal("no version bump type specififed?")
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,7 +88,11 @@ func SetBumpType(bumpType string) {
|
||||
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
|
||||
var path string
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
config, err := recipe.GetComposeConfig(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, service := range config.Services {
|
||||
if service.Name == "app" {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
@ -94,9 +100,7 @@ func GetMainAppImage(recipe recipe.Recipe) (string, error) {
|
||||
}
|
||||
|
||||
path = reference.Path(img)
|
||||
if strings.Contains(path, "library") {
|
||||
path = strings.Split(path, "/")[1]
|
||||
}
|
||||
path = formatter.StripTagMeta(path)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
@ -1,143 +1,118 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// AppName is used for configuring app name programmatically
|
||||
var AppName string
|
||||
|
||||
// ValidateRecipe ensures the recipe arg is valid.
|
||||
func ValidateRecipe(c *cli.Context) recipe.Recipe {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
|
||||
var recipeName string
|
||||
if len(args) > 0 {
|
||||
recipeName = args[0]
|
||||
}
|
||||
|
||||
chosenRecipe, err := recipe.Get(recipeName)
|
||||
var recipes []string
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue(Offline)
|
||||
if err != nil {
|
||||
if c.Command.Name == "generate" {
|
||||
if strings.Contains(err.Error(), "missing a compose") {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Warn(err)
|
||||
} else {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as recipe argument", recipeName)
|
||||
|
||||
return chosenRecipe
|
||||
}
|
||||
|
||||
// ValidateRecipeWithPrompt ensures a recipe argument is present before
|
||||
// validating, asking for input if required.
|
||||
func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" && !NoInput {
|
||||
var recipes []string
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
knownRecipes := make(map[string]bool)
|
||||
for name := range catl {
|
||||
knownRecipes[name] = true
|
||||
}
|
||||
|
||||
localRecipes, err := recipe.GetRecipesLocal()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
knownRecipes := make(map[string]bool)
|
||||
for name := range catl {
|
||||
knownRecipes[name] = true
|
||||
}
|
||||
|
||||
localRecipes, err := recipe.GetRecipesLocal()
|
||||
if err != nil {
|
||||
log.Debugf("can't read local recipes: %s", err)
|
||||
} else {
|
||||
for _, recipeLocal := range localRecipes {
|
||||
if _, ok := knownRecipes[recipeLocal]; !ok {
|
||||
knownRecipes[recipeLocal] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for recipeName := range knownRecipes {
|
||||
recipes = append(recipes, recipeName)
|
||||
}
|
||||
for recipeName := range knownRecipes {
|
||||
recipes = append(recipes, recipeName)
|
||||
}
|
||||
|
||||
if recipeName == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select recipe",
|
||||
Options: recipes,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if RecipeName != "" {
|
||||
recipeName = RecipeName
|
||||
logrus.Debugf("programmatically setting recipe name to %s", recipeName)
|
||||
}
|
||||
|
||||
if recipeName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
log.Fatal("no recipe name provided")
|
||||
}
|
||||
|
||||
chosenRecipe, err := recipe.Get(recipeName)
|
||||
if _, ok := knownRecipes[recipeName]; !ok {
|
||||
if !strings.Contains(recipeName, "/") {
|
||||
log.Fatalf("no recipe '%s' exists?", recipeName)
|
||||
}
|
||||
}
|
||||
|
||||
chosenRecipe := recipe.Get(recipeName)
|
||||
if err := chosenRecipe.EnsureExists(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = chosenRecipe.GetComposeConfig(nil)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
if cmdName == "generate" {
|
||||
if strings.Contains(err.Error(), "missing a compose") {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Warn(err)
|
||||
} else {
|
||||
if strings.Contains(err.Error(), "template_driver is not allowed") {
|
||||
log.Warnf("ensure %s recipe compose.* files include \"version: '3.8'\"", recipeName)
|
||||
}
|
||||
log.Fatalf("unable to validate recipe: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as recipe argument", recipeName)
|
||||
log.Debugf("validated %s as recipe argument", recipeName)
|
||||
|
||||
return chosenRecipe
|
||||
}
|
||||
|
||||
// ValidateApp ensures the app name arg is valid.
|
||||
func ValidateApp(c *cli.Context) config.App {
|
||||
appName := c.Args().First()
|
||||
|
||||
if AppName != "" {
|
||||
appName = AppName
|
||||
logrus.Debugf("programmatically setting app name to %s", appName)
|
||||
func ValidateApp(args []string) app.App {
|
||||
if len(args) == 0 {
|
||||
log.Fatal("no app provided")
|
||||
}
|
||||
|
||||
if appName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
||||
}
|
||||
appName := args[0]
|
||||
|
||||
app, err := app.Get(appName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := recipe.EnsureExists(app.Type); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ssh.EnsureHostKey(app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as app argument", appName)
|
||||
log.Debugf("validated %s as app argument", appName)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// ValidateDomain ensures the domain name arg is valid.
|
||||
func ValidateDomain(c *cli.Context) (string, error) {
|
||||
domainName := c.Args().First()
|
||||
func ValidateDomain(args []string) string {
|
||||
var domainName string
|
||||
if len(args) > 0 {
|
||||
domainName = args[0]
|
||||
}
|
||||
|
||||
if domainName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
@ -145,40 +120,29 @@ func ValidateDomain(c *cli.Context) (string, error) {
|
||||
Default: "example.com",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &domainName); err != nil {
|
||||
return domainName, err
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if domainName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no domain provided"))
|
||||
log.Fatal("no domain provided")
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as domain argument", domainName)
|
||||
log.Debugf("validated %s as domain argument", domainName)
|
||||
|
||||
return domainName, nil
|
||||
}
|
||||
|
||||
// ValidateSubCmdFlags ensures flag order conforms to correct order
|
||||
func ValidateSubCmdFlags(c *cli.Context) bool {
|
||||
for argIdx, arg := range c.Args().Slice() {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
for _, flag := range c.Args().Slice()[argIdx:] {
|
||||
if strings.HasPrefix(flag, "--") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return domainName
|
||||
}
|
||||
|
||||
// ValidateServer ensures the server name arg is valid.
|
||||
func ValidateServer(c *cli.Context) (string, error) {
|
||||
serverName := c.Args().First()
|
||||
func ValidateServer(args []string) string {
|
||||
var serverName string
|
||||
if len(args) > 0 {
|
||||
serverName = args[0]
|
||||
}
|
||||
|
||||
serverNames, err := config.ReadServerNames()
|
||||
if err != nil {
|
||||
return serverName, err
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if serverName == "" && !NoInput {
|
||||
@ -187,309 +151,26 @@ func ValidateServer(c *cli.Context) (string, error) {
|
||||
Options: serverNames,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &serverName); err != nil {
|
||||
return serverName, err
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, name := range serverNames {
|
||||
if name == serverName {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if serverName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no server provided"))
|
||||
log.Fatal("no server provided")
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as server argument", serverName)
|
||||
if !matched {
|
||||
log.Fatal("server doesn't exist?")
|
||||
}
|
||||
|
||||
return serverName, nil
|
||||
}
|
||||
|
||||
// EnsureDNSProvider ensures a DNS provider is chosen.
|
||||
func EnsureDNSProvider() error {
|
||||
if DNSProvider == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select DNS provider",
|
||||
Options: []string{"gandi"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSProvider == "" {
|
||||
return fmt.Errorf("missing DNS provider?")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSTypeFlag ensures a DNS type flag is present.
|
||||
func EnsureDNSTypeFlag(c *cli.Context) error {
|
||||
if DNSType == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record type",
|
||||
Default: "A",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSType == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSNameFlag ensures a DNS name flag is present.
|
||||
func EnsureDNSNameFlag(c *cli.Context) error {
|
||||
if DNSName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record name",
|
||||
Default: "mysubdomain",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSValueFlag ensures a DNS value flag is present.
|
||||
func EnsureDNSValueFlag(c *cli.Context) error {
|
||||
if DNSValue == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record value",
|
||||
Default: "192.168.1.2",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSValue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSValue == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureZoneArgument ensures a zone argument is present.
|
||||
func EnsureZoneArgument(c *cli.Context) (string, error) {
|
||||
zone := c.Args().First()
|
||||
|
||||
if zone == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify a domain name zone",
|
||||
Default: "example.com",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &zone); err != nil {
|
||||
return zone, err
|
||||
}
|
||||
}
|
||||
|
||||
if zone == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
|
||||
}
|
||||
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
// EnsureServerProvider ensures a 3rd party server provider is chosen.
|
||||
func EnsureServerProvider() error {
|
||||
if ServerProvider == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select server provider",
|
||||
Options: []string{"capsul", "hetzner-cloud"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ServerProvider == "" {
|
||||
return fmt.Errorf("missing server provider?")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureNewCapsulVPSFlags ensure all flags are present.
|
||||
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
|
||||
if CapsulName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul name",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul instance URL",
|
||||
Default: CapsulInstanceURL,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul type",
|
||||
Default: CapsulType,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul image",
|
||||
Default: CapsulImage,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
|
||||
var sshKeys string
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul SSH keys (e.g. me@foo.com)",
|
||||
Default: "",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
|
||||
}
|
||||
|
||||
if CapsulAPIToken == "" && !NoInput {
|
||||
token, ok := os.LookupEnv("CAPSUL_TOKEN")
|
||||
if !ok {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul API token",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulAPIToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
CapsulAPIToken = token
|
||||
}
|
||||
}
|
||||
|
||||
if CapsulName == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul name?"))
|
||||
}
|
||||
if CapsulInstanceURL == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul instance url?"))
|
||||
}
|
||||
if CapsulType == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul type?"))
|
||||
}
|
||||
if CapsulImage == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul image?"))
|
||||
}
|
||||
if len(CapsulSSHKeys.Value()) == 0 {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul ssh keys?"))
|
||||
}
|
||||
if CapsulAPIToken == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul API token?"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureNewHetznerCloudVPSFlags ensure all flags are present.
|
||||
func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
|
||||
if HetznerCloudName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS name",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS type",
|
||||
Default: HetznerCloudType,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS image",
|
||||
Default: HetznerCloudImage,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudImage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(HetznerCloudSSHKeys.Value()) == 0 && !NoInput {
|
||||
var sshKeys string
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud SSH keys (e.g. me@foo.com)",
|
||||
Default: "",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &sshKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS location",
|
||||
Default: HetznerCloudLocation,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if HetznerCloudAPIToken == "" && !NoInput {
|
||||
token, ok := os.LookupEnv("HCLOUD_TOKEN")
|
||||
if !ok {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud API token",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
HetznerCloudAPIToken = token
|
||||
}
|
||||
}
|
||||
|
||||
if HetznerCloudName == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
|
||||
}
|
||||
if HetznerCloudType == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
|
||||
}
|
||||
if HetznerCloudImage == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
|
||||
}
|
||||
if HetznerCloudLocation == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
|
||||
}
|
||||
if HetznerCloudAPIToken == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
|
||||
}
|
||||
|
||||
return nil
|
||||
log.Debugf("validated %s as server argument", serverName)
|
||||
|
||||
return serverName
|
||||
}
|
||||
|
||||
29
cli/recipe/diff.go
Normal file
29
cli/recipe/diff.go
Normal file
@ -0,0 +1,29 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var RecipeDiffCommand = &cobra.Command{
|
||||
Use: "diff <recipe> [flags]",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Show unstaged changes in recipe config",
|
||||
Long: "This command requires /usr/bin/git.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
r := internal.ValidateRecipe(args, cmd.Name())
|
||||
if err := gitPkg.DiffUnstaged(r.Dir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
134
cli/recipe/fetch.go
Normal file
134
cli/recipe/fetch.go
Normal file
@ -0,0 +1,134 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/go-git/go-git/v5"
|
||||
gitCfg "github.com/go-git/go-git/v5/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var RecipeFetchCommand = &cobra.Command{
|
||||
Use: "fetch [recipe | --all] [flags]",
|
||||
Aliases: []string{"f"},
|
||||
Short: "Clone recipe(s) locally",
|
||||
Long: `Using "--force/-f" Git syncs an existing recipe. It does not erase unstaged changes.`,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
Example: ` # fetch from recipe catalogue
|
||||
abra recipe fetch gitea
|
||||
|
||||
# fetch from remote recipe
|
||||
abra recipe fetch git.foo.org/recipes/myrecipe
|
||||
|
||||
# fetch with ssh remote for hacking
|
||||
abra recipe fetch gitea --ssh`,
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var recipeName string
|
||||
if len(args) > 0 {
|
||||
recipeName = args[0]
|
||||
}
|
||||
|
||||
if recipeName == "" && !fetchAllRecipes {
|
||||
log.Fatal("missing [recipe] or --all/-a")
|
||||
}
|
||||
|
||||
if recipeName != "" && fetchAllRecipes {
|
||||
log.Fatal("cannot use [recipe] and --all/-a together")
|
||||
}
|
||||
|
||||
if recipeName != "" {
|
||||
r := recipe.Get(recipeName)
|
||||
if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
|
||||
if !force {
|
||||
log.Warnf("%s is already fetched", r.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
r = internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
if sshRemote {
|
||||
if r.SSHURL == "" {
|
||||
log.Warnf("unable to discover SSH remote for %s", r.Name)
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(r.Dir)
|
||||
if err != nil {
|
||||
log.Fatalf("unable to open %s: %s", r.Dir, err)
|
||||
}
|
||||
|
||||
if err = repo.DeleteRemote("origin"); err != nil {
|
||||
log.Fatalf("unable to remove default remote in %s: %s", r.Dir, err)
|
||||
}
|
||||
|
||||
if _, err := repo.CreateRemote(&gitCfg.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{r.SSHURL},
|
||||
}); err != nil {
|
||||
log.Fatalf("unable to set SSH remote in %s: %s", r.Dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
catlBar := formatter.CreateProgressbar(len(catalogue), "fetching latest recipes...")
|
||||
ensureCtx := internal.GetEnsureContext()
|
||||
for recipeName := range catalogue {
|
||||
r := recipe.Get(recipeName)
|
||||
if err := r.Ensure(ensureCtx); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
catlBar.Add(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
fetchAllRecipes bool
|
||||
sshRemote bool
|
||||
force bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeFetchCommand.Flags().BoolVarP(
|
||||
&fetchAllRecipes,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"fetch all recipes",
|
||||
)
|
||||
|
||||
RecipeFetchCommand.Flags().BoolVarP(
|
||||
&sshRemote,
|
||||
"ssh",
|
||||
"s",
|
||||
false,
|
||||
"automatically set ssh remote",
|
||||
)
|
||||
|
||||
RecipeFetchCommand.Flags().BoolVarP(
|
||||
&force,
|
||||
"force",
|
||||
"f",
|
||||
false,
|
||||
"force re-fetch",
|
||||
)
|
||||
}
|
||||
@ -1,75 +1,140 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var recipeLintCommand = &cli.Command{
|
||||
Name: "lint",
|
||||
Usage: "Lint a recipe",
|
||||
Aliases: []string{"l"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{internal.OnlyErrorFlag},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
var RecipeLintCommand = &cobra.Command{
|
||||
Use: "lint <recipe> [flags]",
|
||||
Short: "Lint a recipe",
|
||||
Aliases: []string{"l"},
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
headers := []string{
|
||||
"ref",
|
||||
"rule",
|
||||
"severity",
|
||||
"satisfied",
|
||||
"skipped",
|
||||
"resolve",
|
||||
}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table.Headers(headers...)
|
||||
|
||||
hasError := false
|
||||
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
|
||||
var rows [][]string
|
||||
var warnMessages []string
|
||||
for level := range lint.LintRules {
|
||||
for _, rule := range lint.LintRules[level] {
|
||||
ok, err := rule.Function(recipe)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
if onlyError && rule.Level != "error" {
|
||||
log.Debugf("skipping %s, does not have level \"error\"", rule.Ref)
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && rule.Level == "error" {
|
||||
hasError = true
|
||||
skipped := false
|
||||
if rule.Skip(recipe) {
|
||||
skipped = true
|
||||
}
|
||||
|
||||
var result string
|
||||
if ok {
|
||||
result = "yes"
|
||||
} else {
|
||||
result = "NO"
|
||||
skippedOutput := "-"
|
||||
if skipped {
|
||||
skippedOutput = "✅"
|
||||
}
|
||||
|
||||
if internal.OnlyErrors {
|
||||
if !ok && rule.Level == "error" {
|
||||
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
|
||||
bar.Add(1)
|
||||
satisfied := false
|
||||
if !skipped {
|
||||
ok, err := rule.Function(recipe)
|
||||
if err != nil {
|
||||
warnMessages = append(warnMessages, err.Error())
|
||||
}
|
||||
|
||||
if !ok && rule.Level == "error" {
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if ok {
|
||||
satisfied = true
|
||||
}
|
||||
} else {
|
||||
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
|
||||
bar.Add(1)
|
||||
}
|
||||
|
||||
satisfiedOutput := "✅"
|
||||
if !satisfied {
|
||||
satisfiedOutput = "❌"
|
||||
if skipped {
|
||||
satisfiedOutput = "-"
|
||||
}
|
||||
}
|
||||
|
||||
row := []string{
|
||||
rule.Ref,
|
||||
rule.Description,
|
||||
rule.Level,
|
||||
satisfiedOutput,
|
||||
skippedOutput,
|
||||
rule.HowToResolve,
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
table.Row(row...)
|
||||
}
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
fmt.Println()
|
||||
table.Render()
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if hasError {
|
||||
logrus.Warn("watch out, some critical errors are present in your recipe config")
|
||||
}
|
||||
for _, warnMsg := range warnMessages {
|
||||
log.Warn(warnMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
if hasError {
|
||||
log.Warnf("critical errors present in %s config", recipe.Name)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
onlyError bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeLintCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
RecipeLintCommand.Flags().BoolVarP(
|
||||
&onlyError,
|
||||
"error",
|
||||
"e",
|
||||
false,
|
||||
"only show errors",
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,56 +2,52 @@ package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var pattern string
|
||||
var patternFlag = &cli.StringFlag{
|
||||
Name: "pattern",
|
||||
Value: "",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Simple string to filter recipes",
|
||||
Destination: &pattern,
|
||||
}
|
||||
|
||||
var recipeListCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List available recipes",
|
||||
var RecipeListCommand = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List recipes",
|
||||
Aliases: []string{"ls"},
|
||||
Flags: []cli.Flag{
|
||||
patternFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
|
||||
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
|
||||
if err := gitPkg.Clone(catalogueDir, url); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
logrus.Fatal(err.Error())
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipes := catl.Flatten()
|
||||
sort.Sort(recipe.ByRecipeName(recipes))
|
||||
|
||||
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
len := 0
|
||||
headers := []string{
|
||||
"name",
|
||||
"category",
|
||||
"status",
|
||||
"healthcheck",
|
||||
"backups",
|
||||
"email",
|
||||
"tests",
|
||||
"SSO",
|
||||
}
|
||||
|
||||
table.Headers(headers...)
|
||||
|
||||
var rows [][]string
|
||||
for _, recipe := range recipes {
|
||||
tableRow := []string{
|
||||
row := []string{
|
||||
recipe.Name,
|
||||
recipe.Category,
|
||||
strconv.Itoa(recipe.Features.Status),
|
||||
@ -64,21 +60,50 @@ var recipeListCommand = &cli.Command{
|
||||
|
||||
if pattern != "" {
|
||||
if strings.Contains(recipe.Name, pattern) {
|
||||
table.Append(tableRow)
|
||||
len++
|
||||
table.Row(row...)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
} else {
|
||||
table.Append(tableRow)
|
||||
len++
|
||||
table.Row(row...)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
|
||||
if len(rows) > 0 {
|
||||
if internal.MachineReadable {
|
||||
out, err := formatter.ToJSON(headers, rows)
|
||||
if err != nil {
|
||||
log.Fatal("unable to render to JSON: %s", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
return
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
pattern string
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeListCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
|
||||
RecipeListCommand.Flags().StringVarP(
|
||||
&pattern,
|
||||
"pattern",
|
||||
"p",
|
||||
"",
|
||||
"filter by recipe",
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,18 +2,17 @@ package recipe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"text/template"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// recipeMetadata is the recipe metadata for the README.md
|
||||
@ -30,91 +29,61 @@ type recipeMetadata struct {
|
||||
SSO string
|
||||
}
|
||||
|
||||
var recipeNewCommand = &cli.Command{
|
||||
Name: "new",
|
||||
Usage: "Create a new recipe",
|
||||
Aliases: []string{"n"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: `
|
||||
This command creates a new recipe.
|
||||
var RecipeNewCommand = &cobra.Command{
|
||||
Use: "new <recipe> [flags]",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new recipe",
|
||||
Long: `A community managed recipe template is used.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipeName := args[0]
|
||||
|
||||
Abra uses our built-in example repository which is available here:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/example
|
||||
|
||||
Files within the example repository make use of the Golang templating system
|
||||
which Abra uses to inject values into the generated recipe folder (e.g. name of
|
||||
recipe and domain in the sample environment config).
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
}
|
||||
|
||||
directory := path.Join(config.RECIPES_DIR, recipeName)
|
||||
if _, err := os.Stat(directory); !os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s recipe directory already exists?", directory)
|
||||
r := recipe.Get(recipeName)
|
||||
if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
|
||||
log.Fatalf("%s recipe directory already exists?", r.Dir)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
|
||||
if err := git.Clone(directory, url); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := git.Clone(r.Dir, url); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git")
|
||||
gitRepo := path.Join(r.Dir, ".git")
|
||||
if err := os.RemoveAll(gitRepo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("removed example git repo in %s", gitRepo)
|
||||
log.Debugf("removed .git repo in %s", gitRepo)
|
||||
|
||||
meta := newRecipeMeta(recipeName)
|
||||
|
||||
toParse := []string{
|
||||
path.Join(config.RECIPES_DIR, recipeName, "README.md"),
|
||||
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"),
|
||||
}
|
||||
for _, path := range toParse {
|
||||
for _, path := range []string{r.ReadmePath, r.SampleEnvPath} {
|
||||
tpl, err := template.ParseFiles(path)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var templated bytes.Buffer
|
||||
if err := tpl.Execute(&templated, meta); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := os.WriteFile(path, templated.Bytes(), 0o644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
newGitRepo := path.Join(config.RECIPES_DIR, recipeName)
|
||||
if err := git.Init(newGitRepo, true); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if err := git.Init(r.Dir, true, gitName, gitEmail); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Your new %s recipe has been created in %s.
|
||||
|
||||
In order to share your recipe, you can upload it the git repository to:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/%s
|
||||
|
||||
If you're not sure how to do that, come chat with us:
|
||||
|
||||
https://docs.coopcloud.tech/contact
|
||||
|
||||
See "abra recipe -h" for additional recipe maintainer commands.
|
||||
|
||||
Happy Hacking!
|
||||
|
||||
`, recipeName, path.Join(config.RECIPES_DIR, recipeName), recipeName))
|
||||
|
||||
return nil
|
||||
log.Infof("new recipe '%s' created: %s", recipeName, path.Join(r.Dir))
|
||||
log.Info("happy hacking 🎉")
|
||||
},
|
||||
}
|
||||
|
||||
@ -133,3 +102,26 @@ func newRecipeMeta(recipeName string) recipeMetadata {
|
||||
SSO: "No",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
gitName string
|
||||
gitEmail string
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeNewCommand.Flags().StringVarP(
|
||||
&gitName,
|
||||
"git-name",
|
||||
"N",
|
||||
"",
|
||||
"Git (user) name to do commits with",
|
||||
)
|
||||
|
||||
RecipeNewCommand.Flags().StringVarP(
|
||||
&gitEmail,
|
||||
"git-email",
|
||||
"e",
|
||||
"",
|
||||
"Git email name to do commits with",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,32 +1,19 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// RecipeCommand defines all recipe related sub-commands.
|
||||
var RecipeCommand = &cli.Command{
|
||||
Name: "recipe",
|
||||
Usage: "Manage recipes",
|
||||
ArgsUsage: "<recipe>",
|
||||
Aliases: []string{"r"},
|
||||
Description: `
|
||||
A recipe is a blueprint for an app. It is a bunch of config files which
|
||||
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
|
||||
Cloud community and you can use Abra to read them and create apps for you.
|
||||
var RecipeCommand = &cobra.Command{
|
||||
Use: "recipe [cmd] [args] [flags]",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Manage recipes",
|
||||
Long: `A recipe is a blueprint for an app.
|
||||
|
||||
It is a bunch of config files which describe how to deploy and maintain an app.
|
||||
Recipes are maintained by the Co-op Cloud community and you can use Abra to
|
||||
read them, deploy them and create apps for you.
|
||||
|
||||
Anyone who uses a recipe can become a maintainer. Maintainers typically make
|
||||
sure the recipe is in good working order and the config upgraded in a timely
|
||||
manner. Abra supports convenient automation for recipe maintainenace, see the
|
||||
"abra recipe upgrade", "abra recipe sync" and "abra recipe release" commands.
|
||||
`,
|
||||
Subcommands: []*cli.Command{
|
||||
recipeListCommand,
|
||||
recipeVersionCommand,
|
||||
recipeReleaseCommand,
|
||||
recipeNewCommand,
|
||||
recipeUpgradeCommand,
|
||||
recipeSyncCommand,
|
||||
recipeLintCommand,
|
||||
},
|
||||
manner.`,
|
||||
}
|
||||
|
||||
@ -1,133 +1,158 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var recipeReleaseCommand = &cli.Command{
|
||||
Name: "release",
|
||||
Usage: "Release a new recipe version",
|
||||
Aliases: []string{"rl"},
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
Description: `
|
||||
This command is used to specify a new version of a recipe. These versions are
|
||||
then published on the Co-op Cloud recipe catalogue. These versions take the
|
||||
following form:
|
||||
var RecipeReleaseCommand = &cobra.Command{
|
||||
Use: "release <recipe> [version] [flags]",
|
||||
Aliases: []string{"rl"},
|
||||
Short: "Release a new recipe version",
|
||||
Long: `Create a new version of a recipe.
|
||||
|
||||
These versions are then published on the Co-op Cloud recipe catalogue. These
|
||||
versions take the following form:
|
||||
|
||||
a.b.c+x.y.z
|
||||
|
||||
Where the "a.b.c" part is a semantic version determined by the maintainer. And
|
||||
the "x.y.z" part is the image tag of the recipe "app" service (the main
|
||||
container which contains the software to be used).
|
||||
Where the "a.b.c" part is a semantic version determined by the maintainer. The
|
||||
"x.y.z" part is the image tag of the recipe "app" service (the main container
|
||||
which contains the software to be used, by naming convention).
|
||||
|
||||
We maintain a semantic versioning scheme ("a.b.c") alongside the libre app
|
||||
We maintain a semantic versioning scheme ("a.b.c") alongside the recipe
|
||||
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
|
||||
recipe updates are properly communicated. I.e. developers of an app might
|
||||
publish a minor version but that might lead to changes in the recipe which are
|
||||
major and therefore require intervention while doing the upgrade work.
|
||||
|
||||
Publish your new release to git.coopcloud.tech with "-p/--publish". This
|
||||
Publish your new release to git.coopcloud.tech with "--publish/-p". This
|
||||
requires that you have permission to git push to these repositories and have
|
||||
your SSH keys configured on your account.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
internal.PublishFlag,
|
||||
your SSH keys configured on your account.`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.RecipeNameComplete()
|
||||
case 1:
|
||||
return autocomplete.RecipeVersionComplete(args[0])
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mainApp, err := internal.GetMainAppImage(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mainAppVersion := imagesTmp[mainApp]
|
||||
if mainAppVersion == "" {
|
||||
logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||
log.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||
}
|
||||
|
||||
var tagString string
|
||||
if len(args) == 2 {
|
||||
tagString = args[1]
|
||||
}
|
||||
|
||||
tagString := c.Args().Get(1)
|
||||
if tagString != "" {
|
||||
if _, err := tagcmp.Parse(tagString); err != nil {
|
||||
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||
log.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||
}
|
||||
}
|
||||
|
||||
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
|
||||
logrus.Fatal("cannot specify tag and bump type at the same time")
|
||||
log.Fatal("cannot specify tag and bump type at the same time")
|
||||
}
|
||||
|
||||
if tagString != "" {
|
||||
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||
var err error
|
||||
tagString, err = getLabelVersion(recipe, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipe.Dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !isClean {
|
||||
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
|
||||
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tags) > 0 {
|
||||
logrus.Warnf("previous git tags detected, assuming this is a new semver release")
|
||||
log.Warnf("previous git tags detected, assuming this is a new semver release")
|
||||
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
|
||||
log.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
|
||||
|
||||
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
|
||||
logrus.Fatal(cleanUpErr)
|
||||
if cleanUpErr := cleanUpTag(recipe, tagString); err != nil {
|
||||
log.Fatal(cleanUpErr)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
// getImageVersions retrieves image versions for a recipe
|
||||
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
||||
var services = make(map[string]string)
|
||||
services := make(map[string]string)
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
config, err := recipe.GetComposeConfig(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missingTag := false
|
||||
for _, service := range config.Services {
|
||||
if service.Image == "" {
|
||||
continue
|
||||
}
|
||||
@ -138,21 +163,27 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
||||
}
|
||||
|
||||
path := reference.Path(img)
|
||||
if strings.Contains(path, "library") {
|
||||
path = strings.Split(path, "/")[1]
|
||||
}
|
||||
|
||||
path = formatter.StripTagMeta(path)
|
||||
|
||||
var tag string
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
tag = img.(reference.NamedTagged).Tag()
|
||||
case reference.Named:
|
||||
return services, fmt.Errorf("%s service is missing image tag?", path)
|
||||
if service.Name == "app" {
|
||||
missingTag = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
services[path] = tag
|
||||
}
|
||||
|
||||
if missingTag {
|
||||
return services, fmt.Errorf("app service is missing image tag?")
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
@ -160,8 +191,7 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
||||
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
|
||||
var err error
|
||||
|
||||
directory := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
repo, err := git.PlainOpen(recipe.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -185,16 +215,20 @@ func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string
|
||||
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
|
||||
}
|
||||
|
||||
if err := addReleaseNotes(recipe, tagString); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := commitRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := tagRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pushRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -215,29 +249,113 @@ func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
|
||||
return git.CreateTagOptions{Message: msg}, nil
|
||||
}
|
||||
|
||||
func commitRelease(recipe recipe.Recipe, tag string) error {
|
||||
if internal.Dry {
|
||||
logrus.Debugf("dry run: no changes committed")
|
||||
// addReleaseNotes checks if the release/next release note exists and moves the
|
||||
// file to release/<tag>.
|
||||
func addReleaseNotes(recipe recipe.Recipe, tag string) error {
|
||||
releaseDir := path.Join(recipe.Dir, "release")
|
||||
if _, err := os.Stat(releaseDir); errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.Mkdir(releaseDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tagReleaseNotePath := path.Join(releaseDir, tag)
|
||||
if _, err := os.Stat(tagReleaseNotePath); err == nil {
|
||||
// Release note for current tag already exist exists.
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
var addNextAsReleaseNotes bool
|
||||
|
||||
nextReleaseNotePath := path.Join(releaseDir, "next")
|
||||
if _, err := os.Stat(nextReleaseNotePath); err == nil {
|
||||
// release/next note exists. Move it to release/<tag>
|
||||
if internal.Dry {
|
||||
log.Debugf("dry run: move release note from 'next' to %s", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "Use release note in release/next?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &addNextAsReleaseNotes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !addNextAsReleaseNotes {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(nextReleaseNotePath, tagReleaseNotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
// NOTE(d1): No release note exists for the current release. Or, we've
|
||||
// already used release/next as the release note
|
||||
if internal.NoInput || addNextAsReleaseNotes {
|
||||
return nil
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipe.Dir())
|
||||
prompt := &survey.Input{
|
||||
Message: "Release Note (leave empty for no release note)",
|
||||
}
|
||||
|
||||
var releaseNote string
|
||||
if err := survey.AskOne(prompt, &releaseNote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if releaseNote == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tagReleaseNotePath, []byte(releaseNote), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func commitRelease(recipe recipe.Recipe, tag string) error {
|
||||
if internal.Dry {
|
||||
log.Debugf("dry run: no changes committed")
|
||||
return nil
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipe.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isClean {
|
||||
if !internal.Dry {
|
||||
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir())
|
||||
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Publish {
|
||||
msg := fmt.Sprintf("chore: publish %s release", tag)
|
||||
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
if err := gitPkg.Commit(repoPath, "compose.**yml", msg, internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := fmt.Sprintf("chore: publish %s release", tag)
|
||||
if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -245,7 +363,7 @@ func commitRelease(recipe recipe.Recipe, tag string) error {
|
||||
|
||||
func tagRelease(tagString string, repo *git.Repository) error {
|
||||
if internal.Dry {
|
||||
logrus.Debugf("dry run: no git tag created (%s)", tagString)
|
||||
log.Debugf("dry run: no git tag created (%s)", tagString)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -265,46 +383,42 @@ func tagRelease(tagString string, repo *git.Repository) error {
|
||||
}
|
||||
|
||||
hash := formatter.SmallSHA(head.Hash().String())
|
||||
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||
log.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRelease(recipe recipe.Recipe, tagString string) error {
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changes published")
|
||||
log.Info("dry run: no changes published")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !internal.Publish && !internal.NoInput {
|
||||
if !publish && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "publish new release?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Publish); err != nil {
|
||||
if err := survey.AskOne(prompt, &publish); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Publish {
|
||||
if publish {
|
||||
if err := recipe.Push(internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !internal.Dry {
|
||||
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
|
||||
logrus.Infof("new release published: %s", url)
|
||||
} else {
|
||||
logrus.Info("dry run: no changes published")
|
||||
}
|
||||
url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
|
||||
log.Infof("new release published: %s", url)
|
||||
} else {
|
||||
log.Info("no -p/--publish passed, not publishing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
|
||||
directory := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
repo, err := git.PlainOpen(recipe.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -317,12 +431,6 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
|
||||
}
|
||||
|
||||
var lastGitTag tagcmp.Tag
|
||||
if tagString == "" {
|
||||
if err := internal.PromptBumpType(tagString); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
parsed, err := tagcmp.Parse(tag)
|
||||
if err != nil {
|
||||
@ -363,13 +471,19 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
|
||||
newTag.Major = strconv.Itoa(now + 1)
|
||||
}
|
||||
|
||||
if tagString == "" {
|
||||
if err := internal.PromptBumpType(tagString, lastGitTag.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Major || internal.Minor || internal.Patch {
|
||||
newTag.Metadata = mainAppVersion
|
||||
tagString = newTag.String()
|
||||
}
|
||||
|
||||
if lastGitTag.String() == tagString {
|
||||
logrus.Fatalf("latest git tag (%s) and synced lable (%s) are the same?", lastGitTag, tagString)
|
||||
log.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
|
||||
}
|
||||
|
||||
if !internal.NoInput {
|
||||
@ -379,33 +493,36 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
|
||||
|
||||
var ok bool
|
||||
if err := survey.AskOne(prompt, &ok); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
logrus.Fatal("exiting as requested")
|
||||
log.Fatal("exiting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
if err := addReleaseNotes(recipe, tagString); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := commitRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatalf("failed to commit changes: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := tagRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatalf("failed to tag release: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := pushRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatalf("failed to publish new release: %s", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanUpTag removes a freshly created tag
|
||||
func cleanUpTag(tag, recipeName string) error {
|
||||
directory := path.Join(config.RECIPES_DIR, recipeName)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
func cleanUpTag(recipe recipe.Recipe, tag string) error {
|
||||
repo, err := git.PlainOpen(recipe.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -416,22 +533,22 @@ func cleanUpTag(tag, recipeName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("removed freshly created tag %s", tag)
|
||||
log.Debugf("removed freshly created tag %s", tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
|
||||
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
|
||||
initTag, err := recipe.GetVersionLabelLocal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if initTag == "" {
|
||||
logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
|
||||
log.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
|
||||
}
|
||||
|
||||
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
|
||||
log.Warnf("discovered %s as currently synced recipe label", initTag)
|
||||
|
||||
if prompt && !internal.NoInput {
|
||||
var response bool
|
||||
@ -447,3 +564,50 @@ func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
|
||||
|
||||
return initTag, nil
|
||||
}
|
||||
|
||||
var (
|
||||
publish bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeReleaseCommand.Flags().BoolVarP(
|
||||
&internal.Dry,
|
||||
"dry-run",
|
||||
"r",
|
||||
false,
|
||||
"report changes that would be made",
|
||||
)
|
||||
|
||||
RecipeReleaseCommand.Flags().BoolVarP(
|
||||
&internal.Major,
|
||||
"major",
|
||||
"x",
|
||||
false,
|
||||
"increase the major part of the version",
|
||||
)
|
||||
|
||||
RecipeReleaseCommand.Flags().BoolVarP(
|
||||
&internal.Minor,
|
||||
"minor",
|
||||
"y",
|
||||
false,
|
||||
"increase the minor part of the version",
|
||||
)
|
||||
|
||||
RecipeReleaseCommand.Flags().BoolVarP(
|
||||
&internal.Patch,
|
||||
"patch",
|
||||
"z",
|
||||
false,
|
||||
"increase the patch part of the version",
|
||||
)
|
||||
|
||||
RecipeReleaseCommand.Flags().BoolVarP(
|
||||
&publish,
|
||||
"publish",
|
||||
"p",
|
||||
false,
|
||||
"publish changes to git.coopcloud.tech",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
46
cli/recipe/reset.go
Normal file
46
cli/recipe/reset.go
Normal file
@ -0,0 +1,46 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var RecipeResetCommand = &cobra.Command{
|
||||
Use: "reset <recipe> [flags]",
|
||||
Aliases: []string{"rs"},
|
||||
Short: "Remove all unstaged changes from recipe config",
|
||||
Long: "WARNING: this will delete your changes. Be Careful.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
r := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
repo, err := git.PlainOpen(r.Dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ref, err := repo.Head()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
opts := &git.ResetOptions{Commit: ref.Hash(), Mode: git.HardReset}
|
||||
if err := worktree.Reset(opts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -2,64 +2,76 @@ package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var recipeSyncCommand = &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Sync recipe version label",
|
||||
Aliases: []string{"s"},
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
},
|
||||
Description: `
|
||||
This command will generate labels for the main recipe service (i.e. by
|
||||
convention, the service named 'app') which corresponds to the following format:
|
||||
var RecipeSyncCommand = &cobra.Command{
|
||||
Use: "sync <recipe> [version] [flags]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Sync recipe version label",
|
||||
Long: `Generate labels for the main recipe service.
|
||||
|
||||
By convention, the service named "app" using the following format:
|
||||
|
||||
coop-cloud.${STACK_NAME}.version=<version>
|
||||
|
||||
Where <version> can be specifed on the command-line or Abra can attempt to
|
||||
Where [version] can be specifed on the command-line or Abra can attempt to
|
||||
auto-generate it for you. The <recipe> configuration will be updated on the
|
||||
local file system.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
||||
local file system.`,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch l := len(args); l {
|
||||
case 0:
|
||||
return autocomplete.RecipeNameComplete()
|
||||
case 1:
|
||||
return autocomplete.RecipeVersionComplete(args[0])
|
||||
default:
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
mainApp, err := internal.GetMainAppImage(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mainAppVersion := imagesTmp[mainApp]
|
||||
|
||||
tags, err := recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var nextTag string
|
||||
if len(args) == 2 {
|
||||
nextTag = args[1]
|
||||
}
|
||||
|
||||
nextTag := c.Args().Get(1)
|
||||
if len(tags) == 0 && nextTag == "" {
|
||||
logrus.Warnf("no git tags found for %s", recipe.Name)
|
||||
log.Warnf("no git tags found for %s", recipe.Name)
|
||||
if internal.NoInput {
|
||||
log.Fatalf("unable to continue, input required for initial version")
|
||||
}
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
The following options are two types of initial semantic version that you can
|
||||
pick for %s that will be published in the recipe catalogue. This follows the
|
||||
@ -85,34 +97,35 @@ likely to change.
|
||||
}
|
||||
|
||||
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
|
||||
}
|
||||
|
||||
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||
if err := internal.PromptBumpType(""); err != nil {
|
||||
logrus.Fatal(err)
|
||||
latestRelease := tags[len(tags)-1]
|
||||
if err := internal.PromptBumpType("", latestRelease); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if nextTag == "" {
|
||||
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(recipeDir)
|
||||
repo, err := git.PlainOpen(recipe.Dir)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var lastGitTag tagcmp.Tag
|
||||
iter, err := repo.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
obj, err := repo.TagObject(ref.Hash())
|
||||
if err != nil {
|
||||
log.Fatal("Tag at commit ", ref.Hash(), " is unannotated or otherwise broken. Please fix it.")
|
||||
return err
|
||||
}
|
||||
|
||||
@ -129,7 +142,7 @@ likely to change.
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// bumpType is used to decide what part of the tag should be incremented
|
||||
@ -137,7 +150,7 @@ likely to change.
|
||||
if bumpType != 0 {
|
||||
// a bitwise check if the number is a power of 2
|
||||
if (bumpType & (bumpType - 1)) != 0 {
|
||||
logrus.Fatal("you can only use one version flag: --major, --minor or --patch")
|
||||
log.Fatal("you can only use one version flag: --major, --minor or --patch")
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,14 +159,14 @@ likely to change.
|
||||
if internal.Patch {
|
||||
now, err := strconv.Atoi(newTag.Patch)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = strconv.Itoa(now + 1)
|
||||
} else if internal.Minor {
|
||||
now, err := strconv.Atoi(newTag.Minor)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
@ -161,7 +174,7 @@ likely to change.
|
||||
} else if internal.Major {
|
||||
now, err := strconv.Atoi(newTag.Major)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
@ -171,25 +184,67 @@ likely to change.
|
||||
}
|
||||
|
||||
newTag.Metadata = mainAppVersion
|
||||
logrus.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
|
||||
log.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
|
||||
nextTag = newTag.String()
|
||||
}
|
||||
|
||||
if _, err := tagcmp.Parse(nextTag); err != nil {
|
||||
logrus.Fatalf("invalid version %s specified", nextTag)
|
||||
log.Fatalf("invalid version %s specified", nextTag)
|
||||
}
|
||||
|
||||
mainService := "app"
|
||||
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
|
||||
if !internal.Dry {
|
||||
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
|
||||
log.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
isClean, err := gitPkg.IsClean(recipe.Dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if !isClean {
|
||||
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
|
||||
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
RecipeSyncCommand.Flags().BoolVarP(
|
||||
&internal.Dry,
|
||||
"dry-run",
|
||||
"r",
|
||||
false,
|
||||
"report changes that would be made",
|
||||
)
|
||||
|
||||
RecipeSyncCommand.Flags().BoolVarP(
|
||||
&internal.Major,
|
||||
"major",
|
||||
"x",
|
||||
false,
|
||||
"increase the major part of the version",
|
||||
)
|
||||
|
||||
RecipeSyncCommand.Flags().BoolVarP(
|
||||
&internal.Minor,
|
||||
"minor",
|
||||
"y",
|
||||
false,
|
||||
"increase the minor part of the version",
|
||||
)
|
||||
|
||||
RecipeSyncCommand.Flags().BoolVarP(
|
||||
&internal.Patch,
|
||||
"patch",
|
||||
"z",
|
||||
false,
|
||||
"increase the patch part of the version",
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package recipe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@ -11,13 +12,14 @@ import (
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/distribution/reference"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type imgPin struct {
|
||||
@ -25,14 +27,22 @@ type imgPin struct {
|
||||
version tagcmp.Tag
|
||||
}
|
||||
|
||||
var recipeUpgradeCommand = &cli.Command{
|
||||
Name: "upgrade",
|
||||
Usage: "Upgrade recipe image tags",
|
||||
// anUpgrade represents a single service upgrade (as within a recipe), and the
|
||||
// list of tags that it can be upgraded to, for serialization purposes.
|
||||
type anUpgrade struct {
|
||||
Service string `json:"service"`
|
||||
Image string `json:"image"`
|
||||
Tag string `json:"tag"`
|
||||
UpgradeTags []string `json:"upgrades"`
|
||||
}
|
||||
|
||||
var RecipeUpgradeCommand = &cobra.Command{
|
||||
Use: "upgrade <recipe> [flags]",
|
||||
Aliases: []string{"u"},
|
||||
Description: `
|
||||
This command reads and attempts to parse all image tags within the given
|
||||
<recipe> configuration and prompt with more recent tags to upgrade to. It will
|
||||
update the relevant compose file tags on the local file system.
|
||||
Short: "Upgrade recipe image tags",
|
||||
Long: `Upgrade a given <recipe> configuration.
|
||||
|
||||
It will update the relevant compose file tags on the local file system.
|
||||
|
||||
Some image tags cannot be parsed because they do not follow some sort of
|
||||
semver-like convention. In this case, all possible tags will be listed and it
|
||||
@ -42,51 +52,57 @@ The command is interactive and will show a select input which allows you to
|
||||
make a seclection. Use the "?" key to see more help on navigating this
|
||||
interface.
|
||||
|
||||
You may invoke this command in "wizard" mode and be prompted for input:
|
||||
|
||||
abra recipe upgrade
|
||||
|
||||
`,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{
|
||||
internal.PatchFlag,
|
||||
internal.MinorFlag,
|
||||
internal.MajorFlag,
|
||||
You may invoke this command in "wizard" mode and be prompted for input.`,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
|
||||
if bumpType != 0 {
|
||||
// a bitwise check if the number is a power of 2
|
||||
if (bumpType & (bumpType - 1)) != 0 {
|
||||
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
|
||||
log.Fatal("you can only use one of: --major, --minor, --patch.")
|
||||
}
|
||||
}
|
||||
|
||||
if internal.MachineReadable {
|
||||
// -m implies -n in this case
|
||||
internal.NoInput = true
|
||||
}
|
||||
|
||||
upgradeList := make(map[string]anUpgrade)
|
||||
|
||||
// check for versions file and load pinned versions
|
||||
versionsPresent := false
|
||||
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
versionsPath := path.Join(recipeDir, "versions")
|
||||
var servicePins = make(map[string]imgPin)
|
||||
versionsPath := path.Join(recipe.Dir, "versions")
|
||||
servicePins := make(map[string]imgPin)
|
||||
if _, err := os.Stat(versionsPath); err == nil {
|
||||
logrus.Debugf("found versions file for %s", recipe.Name)
|
||||
log.Debugf("found versions file for %s", recipe.Name)
|
||||
file, err := os.Open(versionsPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
splitLine := strings.Split(line, " ")
|
||||
if splitLine[0] != "pin" || len(splitLine) != 3 {
|
||||
logrus.Fatalf("malformed version pin specification: %s", line)
|
||||
log.Fatalf("malformed version pin specification: %s", line)
|
||||
}
|
||||
pinSlice := strings.Split(splitLine[2], ":")
|
||||
pinTag, err := tagcmp.Parse(pinSlice[1])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
pin := imgPin{
|
||||
image: pinSlice[0],
|
||||
@ -95,46 +111,54 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
servicePins[splitLine[1]] = pin
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
logrus.Error(err)
|
||||
log.Error(err)
|
||||
}
|
||||
versionsPresent = true
|
||||
} else {
|
||||
logrus.Debugf("did not find versions file for %s", recipe.Name)
|
||||
log.Debugf("did not find versions file for %s", recipe.Name)
|
||||
}
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
config, err := recipe.GetComposeConfig(nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, service := range config.Services {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
regVersions, err := client.GetRegistryTags(img)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
image := reference.Path(img)
|
||||
regVersions, err := client.GetRegistryTags(image)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
|
||||
log.Debugf("retrieved %s from remote registry for %s", regVersions, image)
|
||||
image = formatter.StripTagMeta(image)
|
||||
|
||||
if strings.Contains(image, "library") {
|
||||
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
|
||||
// postgres:<tag>, i.e. images which do not have a username in the
|
||||
// first position of the string
|
||||
image = strings.Split(image, "/")[1]
|
||||
}
|
||||
semverLikeTag := true
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
||||
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
|
||||
semverLikeTag = false
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
||||
log.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
|
||||
}
|
||||
default:
|
||||
log.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
|
||||
if err != nil && semverLikeTag {
|
||||
logrus.Fatal(err)
|
||||
if err != nil {
|
||||
log.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name)
|
||||
continue
|
||||
}
|
||||
logrus.Debugf("parsed %s for %s", tag, service.Name)
|
||||
|
||||
log.Debugf("parsed %s for %s", tag, service.Name)
|
||||
|
||||
var compatible []tagcmp.Tag
|
||||
for _, regVersion := range regVersions {
|
||||
other, err := tagcmp.Parse(regVersion.Name)
|
||||
other, err := tagcmp.Parse(regVersion)
|
||||
if err != nil {
|
||||
continue // skip tags that cannot be parsed
|
||||
}
|
||||
@ -144,18 +168,18 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)
|
||||
log.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)
|
||||
|
||||
sort.Sort(tagcmp.ByTagDesc(compatible))
|
||||
|
||||
if len(compatible) == 0 && semverLikeTag {
|
||||
logrus.Info(fmt.Sprintf("no new versions available for %s, %s is the latest", image, tag))
|
||||
if len(compatible) == 0 && !allTags {
|
||||
log.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
|
||||
continue // skip on to the next tag and don't update any compose files
|
||||
}
|
||||
|
||||
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name)
|
||||
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
compatibleStrings := []string{"skip"}
|
||||
@ -171,7 +195,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
|
||||
log.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
|
||||
|
||||
var upgradeTag string
|
||||
_, ok := servicePins[service.Name]
|
||||
@ -188,13 +212,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
}
|
||||
}
|
||||
if contains {
|
||||
logrus.Infof("Upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
|
||||
log.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
|
||||
} else {
|
||||
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
|
||||
log.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
logrus.Fatalf("Service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
|
||||
log.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
@ -202,7 +226,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
for _, upTag := range compatible {
|
||||
upElement, err := tag.UpgradeDelta(upTag)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
delta := upElement.UpgradeType()
|
||||
if delta <= bumpType {
|
||||
@ -211,42 +235,147 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
||||
}
|
||||
}
|
||||
if upgradeTag == "" {
|
||||
logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants.", tag.String(), compatible[0].String(), image)
|
||||
log.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
msg := fmt.Sprintf("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || allTags {
|
||||
tag := img.(reference.NamedTagged).Tag()
|
||||
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
|
||||
if !allTags {
|
||||
log.Warn(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
|
||||
}
|
||||
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
|
||||
compatibleStrings = []string{}
|
||||
compatibleStrings = []string{"skip"}
|
||||
for _, regVersion := range regVersions {
|
||||
compatibleStrings = append(compatibleStrings, regVersion.Name)
|
||||
compatibleStrings = append(compatibleStrings, regVersion)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: msg,
|
||||
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: compatibleStrings,
|
||||
// there is always at least the item "skip" in compatibleStrings (a list of
|
||||
// possible upgradable tags) and at least one other tag.
|
||||
upgradableTags := compatibleStrings[1:]
|
||||
upgrade := anUpgrade{
|
||||
Service: service.Name,
|
||||
Image: image,
|
||||
Tag: tag.String(),
|
||||
UpgradeTags: make([]string, len(upgradableTags)),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
|
||||
logrus.Fatal(err)
|
||||
|
||||
for n, s := range upgradableTags {
|
||||
var sb strings.Builder
|
||||
if _, err := sb.WriteString(s); err != nil {
|
||||
}
|
||||
upgrade.UpgradeTags[n] = sb.String()
|
||||
}
|
||||
|
||||
upgradeList[upgrade.Service] = upgrade
|
||||
|
||||
if internal.NoInput {
|
||||
upgradeTag = "skip"
|
||||
} else {
|
||||
prompt := &survey.Select{
|
||||
Message: msg,
|
||||
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: compatibleStrings,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if upgradeTag != "skip" {
|
||||
if err := recipe.UpdateTag(image, upgradeTag); err != nil {
|
||||
logrus.Fatal(err)
|
||||
ok, err := recipe.UpdateTag(image, upgradeTag)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
log.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
|
||||
}
|
||||
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
|
||||
} else {
|
||||
logrus.Warnf("not upgrading %s, skipping as requested", image)
|
||||
if !internal.NoInput {
|
||||
log.Warnf("not upgrading %s, skipping as requested", image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if internal.NoInput {
|
||||
if internal.MachineReadable {
|
||||
jsonstring, err := json.Marshal(upgradeList)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonstring))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, upgrade := range upgradeList {
|
||||
log.Infof("can upgrade service: %s, image: %s, tag: %s ::", upgrade.Service, upgrade.Image, upgrade.Tag)
|
||||
for _, utag := range upgrade.UpgradeTags {
|
||||
log.Infof(" %s", utag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipe.Dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if !isClean {
|
||||
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
|
||||
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
allTags bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
RecipeUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.Major,
|
||||
"major",
|
||||
"x",
|
||||
false,
|
||||
"increase the major part of the version",
|
||||
)
|
||||
|
||||
RecipeUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.Minor,
|
||||
"minor",
|
||||
"y",
|
||||
false,
|
||||
"increase the minor part of the version",
|
||||
)
|
||||
|
||||
RecipeUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.Patch,
|
||||
"patch",
|
||||
"z",
|
||||
false,
|
||||
"increase the patch part of the version",
|
||||
)
|
||||
|
||||
RecipeUpgradeCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
|
||||
RecipeUpgradeCommand.Flags().BoolVarP(
|
||||
&allTags,
|
||||
"all-tags",
|
||||
"a",
|
||||
false,
|
||||
"list all tags, not just upgrades",
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,62 +2,134 @@ package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var recipeVersionCommand = &cli.Command{
|
||||
Name: "versions",
|
||||
Usage: "List recipe versions",
|
||||
Aliases: []string{"v"},
|
||||
ArgsUsage: "<recipe>",
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
var RecipeVersionCommand = &cobra.Command{
|
||||
Use: "versions <recipe> [flags]",
|
||||
Aliases: []string{"v"},
|
||||
Short: "List recipe versions",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.RecipeNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var warnMessages []string
|
||||
|
||||
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
|
||||
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
|
||||
if err := gitPkg.Clone(catalogueDir, url); err != nil {
|
||||
return err
|
||||
}
|
||||
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||
|
||||
catalogue, err := recipePkg.ReadRecipeCatalogue()
|
||||
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
recipeMeta, ok := catalogue[recipe.Name]
|
||||
recipeMeta, ok := catl[recipe.Name]
|
||||
if !ok {
|
||||
logrus.Fatalf("%s recipe doesn't exist?", recipe.Name)
|
||||
warnMessages = append(warnMessages, "retrieved versions from local recipe repository")
|
||||
|
||||
recipeVersions, warnMsg, err := recipe.GetRecipeVersions()
|
||||
if err != nil {
|
||||
warnMessages = append(warnMessages, err.Error())
|
||||
}
|
||||
if len(warnMsg) > 0 {
|
||||
warnMessages = append(warnMessages, warnMsg...)
|
||||
}
|
||||
|
||||
recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions}
|
||||
}
|
||||
|
||||
tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
if len(recipeMeta.Versions) == 0 {
|
||||
log.Fatalf("%s has no published versions?", recipe.Name)
|
||||
}
|
||||
|
||||
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
|
||||
for tag, meta := range recipeMeta.Versions[i] {
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
table.Headers("SERVICE", "IMAGE", "TAG", "VERSION")
|
||||
|
||||
for version, meta := range recipeMeta.Versions[i] {
|
||||
var allRows [][]string
|
||||
var rows [][]string
|
||||
|
||||
for service, serviceMeta := range meta {
|
||||
table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest})
|
||||
recipeVersion := version
|
||||
if service != "app" {
|
||||
recipeVersion = ""
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
service,
|
||||
serviceMeta.Image,
|
||||
serviceMeta.Tag,
|
||||
recipeVersion,
|
||||
})
|
||||
|
||||
allRows = append(allRows, []string{
|
||||
version,
|
||||
service,
|
||||
serviceMeta.Image,
|
||||
serviceMeta.Tag,
|
||||
recipeVersion,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(rows, sortServiceByName(rows))
|
||||
|
||||
table.Rows(rows...)
|
||||
|
||||
if !internal.MachineReadable {
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if internal.MachineReadable {
|
||||
sort.Slice(allRows, sortServiceByName(allRows))
|
||||
headers := []string{"VERSION", "SERVICE", "NAME", "TAG"}
|
||||
out, err := formatter.ToJSON(headers, allRows)
|
||||
if err != nil {
|
||||
log.Fatal("unable to render to JSON: %s", err)
|
||||
}
|
||||
fmt.Println(out)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.SetAutoMergeCells(true)
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
} else {
|
||||
logrus.Fatalf("%s has no published versions?", recipe.Name)
|
||||
if !internal.MachineReadable {
|
||||
for _, warnMsg := range warnMessages {
|
||||
log.Warn(warnMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func sortServiceByName(versions [][]string) func(i, j int) bool {
|
||||
return func(i, j int) bool {
|
||||
return versions[i][0] < versions[j][0]
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
RecipeVersionCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// RecordListCommand lists domains.
|
||||
var RecordListCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List domain name records",
|
||||
Aliases: []string{"ls"},
|
||||
ArgsUsage: "<zone>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DNSProviderFlag,
|
||||
},
|
||||
Description: `
|
||||
This command lists all domain name records managed by a 3rd party provider for
|
||||
a specific zone.
|
||||
|
||||
You must specify a zone (e.g. example.com) under which your domain name records
|
||||
are listed. This zone must already be created on your provider account.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := internal.EnsureDNSProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
zone, err := internal.EnsureZoneArgument(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var provider gandi.Provider
|
||||
switch internal.DNSProvider {
|
||||
case "gandi":
|
||||
provider, err = gandiPkg.New()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
default:
|
||||
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
|
||||
}
|
||||
|
||||
records, err := provider.GetRecords(c.Context, zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, record := range records {
|
||||
value := record.Value
|
||||
if len(record.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", record.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
record.Type,
|
||||
record.Name,
|
||||
value,
|
||||
record.TTL.String(),
|
||||
strconv.Itoa(record.Priority),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@ -1,252 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// RecordNewCommand creates a new domain name record.
|
||||
var RecordNewCommand = &cli.Command{
|
||||
Name: "new",
|
||||
Usage: "Create a new domain record",
|
||||
Aliases: []string{"n"},
|
||||
ArgsUsage: "<zone>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DNSProviderFlag,
|
||||
internal.DNSTypeFlag,
|
||||
internal.DNSNameFlag,
|
||||
internal.DNSValueFlag,
|
||||
internal.DNSTTLFlag,
|
||||
internal.DNSPriorityFlag,
|
||||
internal.AutoDNSRecordFlag,
|
||||
},
|
||||
Description: `
|
||||
This command creates a new domain name record for a specific zone.
|
||||
|
||||
You must specify a zone (e.g. example.com) under which your domain name records
|
||||
are listed. This zone must already be created on your provider account.
|
||||
|
||||
Example:
|
||||
|
||||
abra record new foo.com -p gandi -t A -n myapp -v 192.168.178.44
|
||||
|
||||
Typically, you need two records, an A record which points at the zone (@.) and
|
||||
a wildcard record for your apps (*.). Pass "--auto" to have Abra automatically
|
||||
set this up.
|
||||
|
||||
abra record new --auto foo.com -p gandi -v 192.168.178.44
|
||||
|
||||
You may also invoke this command in "wizard" mode and be prompted for input
|
||||
|
||||
abra record new
|
||||
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
zone, err := internal.EnsureZoneArgument(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var provider gandi.Provider
|
||||
switch internal.DNSProvider {
|
||||
case "gandi":
|
||||
provider, err = gandiPkg.New()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
default:
|
||||
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
|
||||
}
|
||||
|
||||
if internal.AutoDNSRecord {
|
||||
ipv4, err := dns.EnsureIPv4(zone)
|
||||
if err != nil {
|
||||
logrus.Debugf("no ipv4 associated with %s, prompting for input", zone)
|
||||
if err := internal.EnsureDNSValueFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
ipv4 = internal.DNSValue
|
||||
}
|
||||
|
||||
logrus.Infof("automatically configuring @./*. A records for %s for %s (--auto)", zone, ipv4)
|
||||
|
||||
if err := autoConfigure(c, &provider, zone, ipv4); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSTypeFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSNameFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSValueFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
ttl, err := dns.GetTTL(internal.DNSTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := libdns.Record{
|
||||
Type: internal.DNSType,
|
||||
Name: internal.DNSName,
|
||||
Value: internal.DNSValue,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
|
||||
record.Priority = internal.DNSPriority
|
||||
}
|
||||
|
||||
records, err := provider.GetRecords(c.Context, zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
for _, existingRecord := range records {
|
||||
if existingRecord.Type == record.Type &&
|
||||
existingRecord.Name == record.Name &&
|
||||
existingRecord.Value == record.Value {
|
||||
logrus.Fatalf("%s record for %s already exists?", record.Type, zone)
|
||||
}
|
||||
}
|
||||
|
||||
createdRecords, err := provider.SetRecords(
|
||||
c.Context,
|
||||
zone,
|
||||
[]libdns.Record{record},
|
||||
)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(createdRecords) == 0 {
|
||||
logrus.Fatal("provider library reports that no record was created?")
|
||||
}
|
||||
|
||||
createdRecord := createdRecords[0]
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
value := createdRecord.Value
|
||||
if len(createdRecord.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", createdRecord.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
createdRecord.Type,
|
||||
createdRecord.Name,
|
||||
value,
|
||||
createdRecord.TTL.String(),
|
||||
strconv.Itoa(createdRecord.Priority),
|
||||
})
|
||||
|
||||
table.Render()
|
||||
|
||||
logrus.Info("record created")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string) error {
|
||||
ttl, err := dns.GetTTL(internal.DNSTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
atRecord := libdns.Record{
|
||||
Type: "A",
|
||||
Name: "@",
|
||||
Value: ipv4,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
wildcardRecord := libdns.Record{
|
||||
Type: "A",
|
||||
Name: "*",
|
||||
Value: ipv4,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
records := []libdns.Record{atRecord, wildcardRecord}
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, record := range records {
|
||||
existingRecords, err := provider.GetRecords(c.Context, zone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
discovered := false
|
||||
for _, existingRecord := range existingRecords {
|
||||
if existingRecord.Type == record.Type &&
|
||||
existingRecord.Name == record.Name &&
|
||||
existingRecord.Value == record.Value {
|
||||
logrus.Warnf("%s record: %s %s for %s already exists?", record.Type, record.Name, record.Value, zone)
|
||||
discovered = true
|
||||
}
|
||||
}
|
||||
|
||||
if discovered {
|
||||
continue
|
||||
}
|
||||
|
||||
createdRecords, err := provider.SetRecords(
|
||||
c.Context,
|
||||
zone,
|
||||
[]libdns.Record{record},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(createdRecords) == 0 {
|
||||
return fmt.Errorf("provider library reports that no record was created?")
|
||||
}
|
||||
|
||||
createdRecord := createdRecords[0]
|
||||
|
||||
value := createdRecord.Value
|
||||
if len(createdRecord.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", createdRecord.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
createdRecord.Type,
|
||||
createdRecord.Name,
|
||||
value,
|
||||
createdRecord.TTL.String(),
|
||||
strconv.Itoa(createdRecord.Priority),
|
||||
})
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// RecordCommand supports managing DNS entries.
|
||||
var RecordCommand = &cli.Command{
|
||||
Name: "record",
|
||||
Usage: "Manage domain name records",
|
||||
Aliases: []string{"rc"},
|
||||
ArgsUsage: "<record>",
|
||||
Description: `
|
||||
This command supports managing domain name records via 3rd party providers such
|
||||
as Gandi DNS. It supports listing, creating and removing all types of records
|
||||
that you might need for managing Co-op Cloud apps.
|
||||
|
||||
The following providers are supported:
|
||||
|
||||
Gandi DNS https://www.gandi.net
|
||||
|
||||
You need an account with such a provider already. Typically, you need to
|
||||
provide an API token on the Abra command-line when using these commands so that
|
||||
you can authenticate with your provider account.
|
||||
|
||||
New providers can be integrated, we welcome change sets. See the underlying DNS
|
||||
library documentation for more. It supports many existing providers and allows
|
||||
to implement new provider support easily.
|
||||
|
||||
https://pkg.go.dev/github.com/libdns/libdns
|
||||
|
||||
`,
|
||||
Subcommands: []*cli.Command{
|
||||
RecordListCommand,
|
||||
RecordNewCommand,
|
||||
RecordRemoveCommand,
|
||||
},
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// RecordRemoveCommand lists domains.
|
||||
var RecordRemoveCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove a domain name record",
|
||||
Aliases: []string{"rm"},
|
||||
ArgsUsage: "<zone>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DNSProviderFlag,
|
||||
internal.DNSTypeFlag,
|
||||
internal.DNSNameFlag,
|
||||
},
|
||||
Description: `
|
||||
This command removes a domain name record for a specific zone.
|
||||
|
||||
It uses the type of record and name to match existing records and choose one
|
||||
for deletion. You must specify a zone (e.g. example.com) under which your
|
||||
domain name records are listed. This zone must already be created on your
|
||||
provider account.
|
||||
|
||||
Example:
|
||||
|
||||
abra record remove foo.com -p gandi -t A -n myapp
|
||||
|
||||
You may also invoke this command in "wizard" mode and be prompted for input
|
||||
|
||||
abra record rm
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
zone, err := internal.EnsureZoneArgument(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var provider gandi.Provider
|
||||
switch internal.DNSProvider {
|
||||
case "gandi":
|
||||
provider, err = gandiPkg.New()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
default:
|
||||
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSTypeFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := internal.EnsureDNSNameFlag(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
records, err := provider.GetRecords(c.Context, zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var toDelete libdns.Record
|
||||
for _, record := range records {
|
||||
if record.Type == internal.DNSType && record.Name == internal.DNSName {
|
||||
toDelete = record
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (libdns.Record{}) == toDelete {
|
||||
logrus.Fatal("provider library reports no matching record?")
|
||||
}
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
value := toDelete.Value
|
||||
if len(toDelete.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", toDelete.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
toDelete.Type,
|
||||
toDelete.Name,
|
||||
value,
|
||||
toDelete.TTL.String(),
|
||||
strconv.Itoa(toDelete.Priority),
|
||||
})
|
||||
|
||||
table.Render()
|
||||
|
||||
if !internal.NoInput {
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with record deletion?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
_, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Info("record successfully deleted")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
221
cli/run.go
Normal file
221
cli/run.go
Normal file
@ -0,0 +1,221 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"coopcloud.tech/abra/cli/app"
|
||||
"coopcloud.tech/abra/cli/catalogue"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/cli/recipe"
|
||||
"coopcloud.tech/abra/cli/server"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
charmLog "github.com/charmbracelet/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
func Run(version, commit string) {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "abra [cmd] [args] [flags]",
|
||||
Short: "The Co-op Cloud command-line utility belt 🎩🐇",
|
||||
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
|
||||
ValidArgs: []string{
|
||||
"app",
|
||||
"autocomplete",
|
||||
"catalogue",
|
||||
"man",
|
||||
"recipe",
|
||||
"server",
|
||||
"upgrade",
|
||||
},
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
dirs := []map[string]os.FileMode{
|
||||
{config.ABRA_DIR: 0764},
|
||||
{config.SERVERS_DIR: 0700},
|
||||
{config.RECIPES_DIR: 0764},
|
||||
{config.LOGS_DIR: 0764},
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
for path, perm := range dir {
|
||||
if err := os.Mkdir(path, perm); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Logger.SetStyles(charmLog.DefaultStyles())
|
||||
charmLog.SetDefault(log.Logger)
|
||||
|
||||
if internal.MachineReadable {
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
if internal.Debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetReportCaller(true)
|
||||
}
|
||||
|
||||
log.Debugf("abra version %s, commit %s", version, commit)
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
manCommand := &cobra.Command{
|
||||
Use: "man [flags]",
|
||||
Aliases: []string{"m"},
|
||||
Short: "Generate manpage",
|
||||
Example: ` # generate the man pages into /usr/local/share/man/man1
|
||||
abra_path=$(which abra) # pass abra absolute path to sudo below
|
||||
sudo $abra_path man
|
||||
sudo mandb
|
||||
|
||||
# read the man pages
|
||||
man abra
|
||||
man abra-app-deploy`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
header := &doc.GenManHeader{
|
||||
Title: "ABRA",
|
||||
Section: "1",
|
||||
}
|
||||
|
||||
manDir := "/usr/local/share/man/man1"
|
||||
if _, err := os.Stat(manDir); os.IsNotExist(err) {
|
||||
log.Fatalf("unable to proceed, '%s' does not exist?")
|
||||
}
|
||||
|
||||
err := doc.GenManTree(rootCmd, header, manDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Info("don't forget to run 'sudo mandb'")
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.Debug,
|
||||
"debug",
|
||||
"d",
|
||||
false,
|
||||
"show debug messages",
|
||||
)
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.NoInput,
|
||||
"no-input",
|
||||
"n",
|
||||
false,
|
||||
"toggle non-interactive mode",
|
||||
)
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.Offline,
|
||||
"offline",
|
||||
"o",
|
||||
false,
|
||||
"prefer offline & filesystem access",
|
||||
)
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.IgnoreEnvVersion,
|
||||
"ignore-env-version",
|
||||
"i",
|
||||
false,
|
||||
"ignore .env version checkout",
|
||||
)
|
||||
|
||||
catalogue.CatalogueCommand.AddCommand(
|
||||
catalogue.CatalogueGenerateCommand,
|
||||
)
|
||||
|
||||
server.ServerCommand.AddCommand(
|
||||
server.ServerAddCommand,
|
||||
server.ServerListCommand,
|
||||
server.ServerPruneCommand,
|
||||
server.ServerRemoveCommand,
|
||||
)
|
||||
|
||||
recipe.RecipeCommand.AddCommand(
|
||||
recipe.RecipeDiffCommand,
|
||||
recipe.RecipeFetchCommand,
|
||||
recipe.RecipeLintCommand,
|
||||
recipe.RecipeListCommand,
|
||||
recipe.RecipeNewCommand,
|
||||
recipe.RecipeReleaseCommand,
|
||||
recipe.RecipeResetCommand,
|
||||
recipe.RecipeSyncCommand,
|
||||
recipe.RecipeUpgradeCommand,
|
||||
recipe.RecipeVersionCommand,
|
||||
)
|
||||
|
||||
rootCmd.AddCommand(
|
||||
UpgradeCommand,
|
||||
AutocompleteCommand,
|
||||
manCommand,
|
||||
app.AppCommand,
|
||||
catalogue.CatalogueCommand,
|
||||
server.ServerCommand,
|
||||
recipe.RecipeCommand,
|
||||
)
|
||||
|
||||
app.AppCmdCommand.AddCommand(
|
||||
app.AppCmdListCommand,
|
||||
)
|
||||
|
||||
app.AppSecretCommand.AddCommand(
|
||||
app.AppSecretGenerateCommand,
|
||||
app.AppSecretInsertCommand,
|
||||
app.AppSecretRmCommand,
|
||||
app.AppSecretLsCommand,
|
||||
)
|
||||
|
||||
app.AppVolumeCommand.AddCommand(
|
||||
app.AppVolumeListCommand,
|
||||
app.AppVolumeRemoveCommand,
|
||||
)
|
||||
|
||||
app.AppBackupCommand.AddCommand(
|
||||
app.AppBackupListCommand,
|
||||
app.AppBackupDownloadCommand,
|
||||
app.AppBackupCreateCommand,
|
||||
app.AppBackupSnapshotsCommand,
|
||||
)
|
||||
|
||||
app.AppCommand.AddCommand(
|
||||
app.AppBackupCommand,
|
||||
app.AppCheckCommand,
|
||||
app.AppCmdCommand,
|
||||
app.AppConfigCommand,
|
||||
app.AppCpCommand,
|
||||
app.AppDeployCommand,
|
||||
app.AppListCommand,
|
||||
app.AppLogsCommand,
|
||||
app.AppNewCommand,
|
||||
app.AppPsCommand,
|
||||
app.AppRemoveCommand,
|
||||
app.AppRestartCommand,
|
||||
app.AppRestoreCommand,
|
||||
app.AppRollbackCommand,
|
||||
app.AppMoveCommand,
|
||||
app.AppRunCommand,
|
||||
app.AppSecretCommand,
|
||||
app.AppServicesCommand,
|
||||
app.AppUndeployCommand,
|
||||
app.AppUpgradeCommand,
|
||||
app.AppVolumeCommand,
|
||||
app.AppLabelsCommand,
|
||||
app.AppEnvCommand,
|
||||
)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@ -1,579 +1,203 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/server"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
sshPkg "coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
dockerInstallMsg = `
|
||||
A docker installation cannot be found on %s. This is a required system
|
||||
dependency for running Co-op Cloud on your server. If you would like, Abra can
|
||||
attempt to install Docker for you using the upstream non-interactive
|
||||
installation script.
|
||||
|
||||
See the following documentation for more:
|
||||
|
||||
https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script
|
||||
|
||||
N.B Docker doesn't recommend it for production environments but many use it for
|
||||
such purposes. Docker stable is now installed by default by this script. The
|
||||
source for this script can be seen here:
|
||||
|
||||
https://github.com/docker/docker-install
|
||||
|
||||
`
|
||||
)
|
||||
|
||||
var local bool
|
||||
var localFlag = &cli.BoolFlag{
|
||||
Name: "local",
|
||||
Aliases: []string{"l"},
|
||||
Value: false,
|
||||
Usage: "Use local server",
|
||||
Destination: &local,
|
||||
}
|
||||
|
||||
var provision bool
|
||||
var provisionFlag = &cli.BoolFlag{
|
||||
Name: "provision",
|
||||
Aliases: []string{"p"},
|
||||
Value: false,
|
||||
Usage: "Provision server so it can deploy apps",
|
||||
Destination: &provision,
|
||||
}
|
||||
|
||||
var sshAuth string
|
||||
var sshAuthFlag = &cli.StringFlag{
|
||||
Name: "ssh-auth",
|
||||
Aliases: []string{"sh"},
|
||||
Value: "identity-file",
|
||||
Usage: "Select SSH authentication method (identity-file, password)",
|
||||
Destination: &sshAuth,
|
||||
}
|
||||
|
||||
var askSudoPass bool
|
||||
var askSudoPassFlag = &cli.BoolFlag{
|
||||
Name: "ask-sudo-pass",
|
||||
Aliases: []string{"as"},
|
||||
Value: false,
|
||||
Usage: "Ask for sudo password",
|
||||
Destination: &askSudoPass,
|
||||
}
|
||||
|
||||
var traefik bool
|
||||
var traefikFlag = &cli.BoolFlag{
|
||||
Name: "traefik",
|
||||
Aliases: []string{"t"},
|
||||
Value: false,
|
||||
Usage: "Deploy traefik",
|
||||
Destination: &traefik,
|
||||
}
|
||||
|
||||
func cleanUp(domainName string) {
|
||||
logrus.Warnf("cleaning up context for %s", domainName)
|
||||
if err := client.DeleteContext(domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Warnf("cleaning up server directory for %s", domainName)
|
||||
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, domainName)); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func installDockerLocal(c *cli.Context) error {
|
||||
fmt.Println(fmt.Sprintf(dockerInstallMsg, "this local server"))
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("attempt install docker on local server?"),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
for _, exe := range []string{"wget", "bash"} {
|
||||
exists, err := ensureLocalExecutable(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s missing, please install it", exe)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", "-c", "wget -O- https://get.docker.com | bash")
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLocalServer(c *cli.Context, domainName string) error {
|
||||
if err := createServerDir(domainName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cl, err := newClient(c, domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if provision {
|
||||
exists, err := ensureLocalExecutable("docker")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err := installDockerLocal(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := initSwarmLocal(c, cl, domainName); err != nil {
|
||||
if !strings.Contains(err.Error(), "proxy already exists") {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if traefik {
|
||||
if err := deployTraefik(c, cl, domainName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Info("local server has been added")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newContext(c *cli.Context, domainName, username, port string) error {
|
||||
store := contextPkg.NewDefaultDockerContextStore()
|
||||
contexts, err := store.Store.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, context := range contexts {
|
||||
if context.Name == domainName {
|
||||
logrus.Debugf("context for %s already exists", domainName)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port)
|
||||
|
||||
if err := client.CreateContext(domainName, username, port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) {
|
||||
cl, err := client.New(domainName)
|
||||
if err != nil {
|
||||
return &dockerClient.Client{}, err
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error {
|
||||
exists, err := ensureRemoteExecutable("docker", sshCl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
fmt.Println(fmt.Sprintf(dockerInstallMsg, domainName))
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("attempt install docker on %s?", domainName),
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
exes := []string{"wget", "bash"}
|
||||
if askSudoPass {
|
||||
exes = append(exes, "ssh-askpass")
|
||||
}
|
||||
|
||||
for _, exe := range exes {
|
||||
exists, err := ensureRemoteExecutable(exe, sshCl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s missing on remote, please install it", exe)
|
||||
}
|
||||
}
|
||||
|
||||
var sudoPass string
|
||||
if askSudoPass {
|
||||
cmd := "wget -O- https://get.docker.com | bash"
|
||||
|
||||
prompt := &survey.Password{
|
||||
Message: "sudo password?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &sudoPass); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("running %s on %s now with sudo password", cmd, domainName)
|
||||
|
||||
if sudoPass == "" {
|
||||
return fmt.Errorf("missing sudo password but requested --ask-sudo-pass?")
|
||||
}
|
||||
|
||||
logrus.Warn("installing docker, this could take some time...")
|
||||
|
||||
if err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Abra was unable to bootstrap Docker, see below for logs:
|
||||
|
||||
|
||||
%s
|
||||
|
||||
If nothing works, you try running the Docker install script manually on your server:
|
||||
|
||||
wget -O- https://get.docker.com | bash
|
||||
|
||||
`, string(err.Error())))
|
||||
logrus.Fatal("Process exited with status 1")
|
||||
}
|
||||
|
||||
logrus.Infof("docker is installed on %s", domainName)
|
||||
|
||||
remoteUser := sshCl.SSHClient.Conn.User()
|
||||
logrus.Infof("adding %s to docker group", remoteUser)
|
||||
permsCmd := fmt.Sprintf("sudo usermod -aG docker %s", remoteUser)
|
||||
if err := ssh.RunSudoCmd(permsCmd, sudoPass, sshCl); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
cmd := "wget -O- https://get.docker.com | bash"
|
||||
|
||||
logrus.Debugf("running %s on %s now without sudo password", cmd, domainName)
|
||||
|
||||
logrus.Warn("installing docker, this could take some time...")
|
||||
|
||||
if out, err := sshCl.Exec(cmd); err != nil {
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Abra was unable to bootstrap Docker, see below for logs:
|
||||
|
||||
|
||||
%s
|
||||
|
||||
This could be due to a number of things but one of the most common is that your
|
||||
server user account does not have sudo access, and if it does, you need to pass
|
||||
"--ask-sudo-pass" in order to supply Abra with your password.
|
||||
|
||||
If nothing works, you try running the Docker install script manually on your server:
|
||||
|
||||
wget -O- https://get.docker.com | bash
|
||||
|
||||
`, string(out)))
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("docker is installed on %s", domainName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSwarmLocal(c *cli.Context, cl *dockerClient.Client, domainName string) error {
|
||||
initReq := swarm.InitRequest{ListenAddr: "0.0.0.0:2377"}
|
||||
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
|
||||
if strings.Contains(err.Error(), "is already part of a swarm") ||
|
||||
strings.Contains(err.Error(), "must specify a listening address") {
|
||||
logrus.Infof("swarm mode already initialised on %s", domainName)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("initialised swarm mode on local server")
|
||||
}
|
||||
|
||||
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
|
||||
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
|
||||
if !strings.Contains(err.Error(), "proxy already exists") {
|
||||
return err
|
||||
}
|
||||
logrus.Info("swarm overlay network already created on local server")
|
||||
} else {
|
||||
logrus.Infof("swarm overlay network created on local server")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error {
|
||||
ipv4, err := dns.EnsureIPv4(domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initReq := swarm.InitRequest{
|
||||
ListenAddr: "0.0.0.0:2377",
|
||||
AdvertiseAddr: ipv4,
|
||||
}
|
||||
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
|
||||
if strings.Contains(err.Error(), "is already part of a swarm") ||
|
||||
strings.Contains(err.Error(), "must specify a listening address") {
|
||||
logrus.Infof("swarm mode already initialised on %s", domainName)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("initialised swarm mode on %s", domainName)
|
||||
}
|
||||
|
||||
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
|
||||
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
|
||||
if !strings.Contains(err.Error(), "proxy already exists") {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("swarm overlay network already created on %s", domainName)
|
||||
} else {
|
||||
logrus.Infof("swarm overlay network created on %s", domainName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createServerDir(domainName string) error {
|
||||
if err := server.CreateServerDir(domainName); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("server dir for %s already created", domainName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error {
|
||||
internal.NoInput = true
|
||||
|
||||
internal.RecipeName = "traefik"
|
||||
internal.NewAppServer = domainName
|
||||
internal.Domain = fmt.Sprintf("%s.%s", "traefik", domainName)
|
||||
internal.NewAppName = fmt.Sprintf("%s_%s", "traefik", config.SanitiseAppName(domainName))
|
||||
|
||||
appEnvPath := path.Join(config.ABRA_DIR, "servers", internal.Domain, fmt.Sprintf("%s.env", internal.NewAppName))
|
||||
if _, err := os.Stat(appEnvPath); os.IsNotExist(err) {
|
||||
logrus.Info(fmt.Sprintf("-t/--traefik specified, automatically deploying traefik to %s", internal.NewAppServer))
|
||||
if err := internal.NewAction(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("%s already exists, not creating again", appEnvPath)
|
||||
}
|
||||
|
||||
internal.AppName = internal.NewAppName
|
||||
if err := internal.DeployAction(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var serverAddCommand = &cli.Command{
|
||||
Name: "add",
|
||||
Usage: "Add a server to your configuration",
|
||||
Description: `
|
||||
This command adds a new server to your configuration so that it can be managed
|
||||
by Abra. This can be useful when you already have a server provisioned and want
|
||||
to start running Abra commands against it.
|
||||
|
||||
This command can also provision your server ("--provision/-p") so that it is
|
||||
capable of hosting Co-op Cloud apps. Abra will default to expecting that you
|
||||
have a running ssh-agent and are using SSH keys to connect to your new server.
|
||||
Abra will also read your SSH config (matching "Host" as <domain>). SSH
|
||||
connection details precedence follows as such: command-line > SSH config >
|
||||
guessed defaults.
|
||||
|
||||
If you have no SSH key configured for this host and are instead using password
|
||||
authentication, you may pass "--ssh-auth password" to have Abra ask you for the
|
||||
password. "--ask-sudo-pass" may be passed if you run your provisioning commands
|
||||
via sudo privilege escalation.
|
||||
var ServerAddCommand = &cobra.Command{
|
||||
Use: "add [[server] | --local] [flags]",
|
||||
Aliases: []string{"a"},
|
||||
Short: "Add a new server",
|
||||
Long: `Add a new server to your configuration so that it can be managed by Abra.
|
||||
|
||||
Abra relies on the standard SSH command-line and ~/.ssh/config for client
|
||||
connection details. You must configure an entry per-host in your ~/.ssh/config
|
||||
for each server:
|
||||
|
||||
Host 1312.net 1312
|
||||
Hostname 1312.net
|
||||
User antifa
|
||||
Port 12345
|
||||
IdentityFile ~/.ssh/antifa@somewhere
|
||||
|
||||
If "--local" is passed, then Abra assumes that the current local server is
|
||||
intended as the target server. This is useful when you want to have your entire
|
||||
Co-op Cloud config located on the server itself, and not on your local
|
||||
developer machine.
|
||||
|
||||
Example:
|
||||
|
||||
abra server add --local
|
||||
|
||||
Otherwise, you may specify a remote server. The <domain> argument must be a
|
||||
publicy accessible domain name which points to your server. You should have SSH
|
||||
access to this server, Abra will assume port 22 and will use your current
|
||||
system username to make an initial connection. You can use the <user> and
|
||||
<port> arguments to adjust this.
|
||||
|
||||
Example:
|
||||
|
||||
abra server add --provision --traefik varia.zone glodemodem 12345
|
||||
|
||||
Abra will construct the following SSH connection and Docker context:
|
||||
|
||||
ssh://globemodem@varia.zone:12345
|
||||
|
||||
All communication between Abra and the server will use this SSH connection.
|
||||
|
||||
In this example, Abra will run the following operations:
|
||||
|
||||
1. Install Docker
|
||||
2. Initialise Swarm mode
|
||||
3. Deploy Traefik (core web proxy)
|
||||
|
||||
You may omit flags to avoid performing this provisioning logic.
|
||||
`,
|
||||
Aliases: []string{"a"},
|
||||
Flags: []cli.Flag{
|
||||
localFlag,
|
||||
provisionFlag,
|
||||
sshAuthFlag,
|
||||
askSudoPassFlag,
|
||||
traefikFlag,
|
||||
developer machine. The domain is then set to "default".`,
|
||||
Example: " abra server add 1312.net",
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if !local {
|
||||
return autocomplete.ServerNameComplete()
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
},
|
||||
ArgsUsage: "<domain> [<user>] [<port>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(c) {
|
||||
err := errors.New("cannot use <domain> and --local together")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) > 0 && local {
|
||||
log.Fatal("cannot use [server] and --local together")
|
||||
}
|
||||
|
||||
if sshAuth != "password" && sshAuth != "identity-file" {
|
||||
err := errors.New("--ssh-auth only accepts identity-file or password")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
if len(args) == 0 && !local {
|
||||
log.Fatal("missing argument or --local/-l flag")
|
||||
}
|
||||
|
||||
name := "default"
|
||||
if !local {
|
||||
name = internal.ValidateDomain(args)
|
||||
}
|
||||
|
||||
// NOTE(d1): reasonable 5 second timeout for connections which can't
|
||||
// succeed. The connection is attempted twice, so this results in 10
|
||||
// seconds.
|
||||
timeout := client.WithTimeout(5)
|
||||
|
||||
if local {
|
||||
if err := newLocalServer(c, "default"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
domainName, err := internal.ValidateDomain(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
username := c.Args().Get(1)
|
||||
if username == "" {
|
||||
systemUser, err := user.Current()
|
||||
created, err := createServerDir(name)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Fatal(err)
|
||||
}
|
||||
username = systemUser.Username
|
||||
|
||||
log.Debugf("attempting to create client for %s", name)
|
||||
|
||||
if _, err := client.New(name, timeout); err != nil {
|
||||
cleanUp(name)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if created {
|
||||
log.Info("local server successfully added")
|
||||
} else {
|
||||
log.Warn("local server already exists")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
port := c.Args().Get(2)
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
if err := createServerDir(domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := newContext(c, domainName, username, port); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := newClient(c, domainName)
|
||||
_, err := createServerDir(name)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if provision {
|
||||
logrus.Debugf("attempting to construct SSH client for %s", domainName)
|
||||
sshCl, err := ssh.New(domainName, sshAuth, username, port)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
defer sshCl.Close()
|
||||
logrus.Debugf("successfully created SSH client for %s", domainName)
|
||||
|
||||
if err := installDocker(c, cl, sshCl, domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if err := initSwarm(c, cl, domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
created, err := newContext(name)
|
||||
if err != nil {
|
||||
cleanUp(name)
|
||||
log.Fatalf("unable to create local context: %s", err)
|
||||
}
|
||||
|
||||
if _, err := cl.Info(c.Context); err != nil {
|
||||
cleanUp(domainName)
|
||||
logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
|
||||
log.Debugf("attempting to create client for %s", name)
|
||||
|
||||
if _, err := client.New(name, timeout); err != nil {
|
||||
cleanUp(name)
|
||||
log.Fatalf("ssh %s error: %s", name, sshPkg.Fatal(name, err))
|
||||
}
|
||||
|
||||
if traefik {
|
||||
if err := deployTraefik(c, cl, domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
if created {
|
||||
log.Infof("%s successfully added", name)
|
||||
|
||||
if _, err := dns.EnsureIPv4(name); err != nil {
|
||||
log.Warnf("unable to resolve IPv4 for %s", name)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
log.Warnf("%s already exists", name)
|
||||
},
|
||||
}
|
||||
|
||||
// ensureLocalExecutable ensures that an executable is present on the local machine
|
||||
func ensureLocalExecutable(exe string) (bool, error) {
|
||||
out, err := exec.Command("which", exe).Output()
|
||||
// cleanUp cleans up the partially created context/client details for a failed
|
||||
// "server add" attempt.
|
||||
func cleanUp(name string) {
|
||||
if name != "default" {
|
||||
log.Debugf("serverAdd: cleanUp: cleaning up context for %s", name)
|
||||
if err := client.DeleteContext(name); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
serverDir := filepath.Join(config.SERVERS_DIR, name)
|
||||
files, err := config.GetAllFilesInDirectory(serverDir)
|
||||
if err != nil {
|
||||
log.Fatalf("serverAdd: cleanUp: unable to list files in %s: %s", serverDir, err)
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
log.Debugf("serverAdd: cleanUp: %s is not empty, aborting cleanup", serverDir)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(serverDir); err != nil {
|
||||
log.Fatalf("serverAdd: cleanUp: failed to remove %s: %s", serverDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newContext creates a new internal Docker context for a server. This is how
|
||||
// Docker manages SSH connection details. These are stored to disk in
|
||||
// ~/.docker. Abra can manage this completely for the user, so it's an
|
||||
// implementation detail.
|
||||
func newContext(name string) (bool, error) {
|
||||
store := contextPkg.NewDefaultDockerContextStore()
|
||||
contexts, err := store.Store.List()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return string(out) != "", nil
|
||||
}
|
||||
|
||||
// ensureRemoteExecutable ensures that an executable is present on a remote machine
|
||||
func ensureRemoteExecutable(exe string, sshCl *ssh.Client) (bool, error) {
|
||||
out, err := sshCl.Exec(fmt.Sprintf("which %s", exe))
|
||||
if err != nil && string(out) != "" {
|
||||
return false, err
|
||||
for _, context := range contexts {
|
||||
if context.Name == name {
|
||||
log.Debugf("context for %s already exists", name)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return string(out) != "", nil
|
||||
log.Debugf("creating context with domain %s", name)
|
||||
|
||||
if err := client.CreateContext(name); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// createServerDir creates the ~/.abra/servers/... directory for a new server.
|
||||
func createServerDir(name string) (bool, error) {
|
||||
if err := server.CreateServerDir(name); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
log.Debugf("server dir for %s already created", name)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var (
|
||||
local bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
ServerAddCommand.Flags().BoolVarP(
|
||||
&local,
|
||||
"local",
|
||||
"l",
|
||||
false,
|
||||
"use local server",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,64 +1,103 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/context"
|
||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/docker/cli/cli/connhelper/ssh"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var serverListCommand = &cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List managed servers",
|
||||
ArgsUsage: " ",
|
||||
HideHelp: true,
|
||||
Action: func(c *cli.Context) error {
|
||||
dockerContextStore := context.NewDefaultDockerContextStore()
|
||||
var ServerListCommand = &cobra.Command{
|
||||
Use: "list [flags]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List managed servers",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dockerContextStore := contextPkg.NewDefaultDockerContextStore()
|
||||
contexts, err := dockerContextStore.Store.List()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tableColumns := []string{"name", "host", "user", "port"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
defer table.Render()
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{"NAME", "HOST"}
|
||||
table.Headers(headers...)
|
||||
|
||||
serverNames, err := config.ReadServerNames()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var rows [][]string
|
||||
for _, serverName := range serverNames {
|
||||
var row []string
|
||||
for _, ctx := range contexts {
|
||||
endpoint, err := context.GetContextEndpoint(ctx)
|
||||
for _, dockerCtx := range contexts {
|
||||
endpoint, err := contextPkg.GetContextEndpoint(dockerCtx)
|
||||
if err != nil && strings.Contains(err.Error(), "does not exist") {
|
||||
// No local context found, we can continue safely
|
||||
continue
|
||||
}
|
||||
if ctx.Name == serverName {
|
||||
|
||||
if dockerCtx.Name == serverName {
|
||||
sp, err := ssh.ParseURL(endpoint)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
row = []string{serverName, sp.Host, sp.User, sp.Port}
|
||||
|
||||
if sp.Host == "" {
|
||||
sp.Host = "unknown"
|
||||
}
|
||||
|
||||
row = []string{serverName, sp.Host}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
if serverName == "default" {
|
||||
row = []string{serverName, "local", "n/a", "n/a"}
|
||||
row = []string{serverName, "local"}
|
||||
} else {
|
||||
row = []string{serverName, "unknown", "unknown", "unknown"}
|
||||
row = []string{serverName, "unknown"}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
table.Append(row)
|
||||
|
||||
table.Row(row...)
|
||||
}
|
||||
|
||||
return nil
|
||||
if internal.MachineReadable {
|
||||
out, err := formatter.ToJSON(headers, rows)
|
||||
if err != nil {
|
||||
log.Fatal("unable to render to JSON: %s", err)
|
||||
}
|
||||
|
||||
fmt.Println(out)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ServerListCommand.Flags().BoolVarP(
|
||||
&internal.MachineReadable,
|
||||
"machine",
|
||||
"m",
|
||||
false,
|
||||
"print machine-readable output",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,261 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/libcapsul"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func newHetznerCloudVPS(c *cli.Context) error {
|
||||
if err := internal.EnsureNewHetznerCloudVPSFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
|
||||
|
||||
var sshKeysRaw []string
|
||||
var sshKeys []*hcloud.SSHKey
|
||||
for _, sshKey := range c.StringSlice("hetzner-ssh-keys") {
|
||||
if sshKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sshKey, _, err := client.SSHKey.GetByName(c.Context, sshKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshKeys = append(sshKeys, sshKey)
|
||||
sshKeysRaw = append(sshKeysRaw, sshKey.Name)
|
||||
}
|
||||
|
||||
serverOpts := hcloud.ServerCreateOpts{
|
||||
Name: internal.HetznerCloudName,
|
||||
ServerType: &hcloud.ServerType{Name: internal.HetznerCloudType},
|
||||
Image: &hcloud.Image{Name: internal.HetznerCloudImage},
|
||||
SSHKeys: sshKeys,
|
||||
Location: &hcloud.Location{Name: internal.HetznerCloudLocation},
|
||||
}
|
||||
|
||||
sshKeyIDs := strings.Join(sshKeysRaw, "\n")
|
||||
if sshKeyIDs == "" {
|
||||
sshKeyIDs = "N/A (password auth)"
|
||||
}
|
||||
|
||||
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
table.Append([]string{
|
||||
internal.HetznerCloudName,
|
||||
internal.HetznerCloudType,
|
||||
internal.HetznerCloudImage,
|
||||
sshKeyIDs,
|
||||
internal.HetznerCloudLocation,
|
||||
})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with hetzner cloud VPS creation?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
res, _, err := client.Server.Create(c.Context, serverOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rootPassword string
|
||||
if len(sshKeys) > 0 {
|
||||
rootPassword = "N/A (using SSH keys)"
|
||||
} else {
|
||||
rootPassword = res.RootPassword
|
||||
}
|
||||
|
||||
ip := res.Server.PublicNet.IPv4.IP.String()
|
||||
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
Your new Hetzner Cloud VPS has successfully been created! Here are the details:
|
||||
|
||||
name: %s
|
||||
IP address: %s
|
||||
root password: %s
|
||||
|
||||
You can access this new VPS via SSH using the following command:
|
||||
|
||||
ssh root@%s
|
||||
|
||||
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
|
||||
not list this server)! You will need to assign a domain name record ("abra
|
||||
record new") and add the server to your Abra configuration ("abra server add")
|
||||
to have a working server that you can deploy Co-op Cloud apps to.
|
||||
|
||||
When setting up domain name records, you probably want to set up the following
|
||||
2 A records. This supports deploying apps to your root domain (e.g.
|
||||
example.com) and other apps on sub-domains (e.g. foo.example.com,
|
||||
bar.example.com).
|
||||
|
||||
@ 1800 IN A %s
|
||||
* 1800 IN A %s
|
||||
|
||||
"abra record new --auto" can help you do this quickly if you use a supported
|
||||
DNS provider.
|
||||
|
||||
`,
|
||||
internal.HetznerCloudName, ip, rootPassword,
|
||||
ip, ip, ip,
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCapsulVPS(c *cli.Context) error {
|
||||
if err := internal.EnsureNewCapsulVPSFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", internal.CapsulInstanceURL)
|
||||
|
||||
var sshKeys []string
|
||||
for _, sshKey := range c.StringSlice("capsul-ssh-keys") {
|
||||
if sshKey == "" {
|
||||
continue
|
||||
}
|
||||
sshKeys = append(sshKeys, sshKey)
|
||||
}
|
||||
|
||||
tableColumns := []string{"instance", "name", "type", "image", "ssh-keys"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
table.Append([]string{
|
||||
internal.CapsulInstanceURL,
|
||||
internal.CapsulName,
|
||||
internal.CapsulType,
|
||||
internal.CapsulImage,
|
||||
strings.Join(sshKeys, "\n"),
|
||||
})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with capsul creation?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
capsulClient := libcapsul.New(capsulCreateURL, internal.CapsulAPIToken)
|
||||
resp, err := capsulClient.Create(
|
||||
internal.CapsulName,
|
||||
internal.CapsulType,
|
||||
internal.CapsulImage,
|
||||
sshKeys,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
Your new Capsul has successfully been created! Here are the details:
|
||||
|
||||
Capsul name: %s
|
||||
Capsul ID: %v
|
||||
|
||||
You will need to log into your Capsul instance web interface to retrieve the IP
|
||||
address. You can learn all about how to get SSH access to your new Capsul on:
|
||||
|
||||
%s/about-ssh
|
||||
|
||||
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
|
||||
not list this server)! You will need to assign a domain name record ("abra
|
||||
record new") and add the server to your Abra configuration ("abra server add")
|
||||
to have a working server that you can deploy Co-op Cloud apps to.
|
||||
|
||||
When setting up domain name records, you probably want to set up the following
|
||||
2 A records. This supports deploying apps to your root domain (e.g.
|
||||
example.com) and other apps on sub-domains (e.g. foo.example.com,
|
||||
bar.example.com).
|
||||
|
||||
@ 1800 IN A <your-capsul-ip>
|
||||
* 1800 IN A <your-capsul-ip>
|
||||
|
||||
`, internal.CapsulName, resp.ID, internal.CapsulInstanceURL))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var serverNewCommand = &cli.Command{
|
||||
Name: "new",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Create a new server using a 3rd party provider",
|
||||
Description: `
|
||||
This command creates a new server via a 3rd party provider.
|
||||
|
||||
The following providers are supported:
|
||||
|
||||
Capsul https://git.cyberia.club/Cyberia/capsul-flask
|
||||
Hetzner Cloud https://docs.hetzner.com/cloud
|
||||
|
||||
You may invoke this command in "wizard" mode and be prompted for input:
|
||||
|
||||
abra record new
|
||||
|
||||
API tokens are read from the environment if specified, e.g.
|
||||
|
||||
export HCLOUD_TOKEN=...
|
||||
|
||||
Where "$provider_TOKEN" is the expected env var format.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
internal.ServerProviderFlag,
|
||||
|
||||
// Capsul
|
||||
internal.CapsulInstanceURLFlag,
|
||||
internal.CapsulTypeFlag,
|
||||
internal.CapsulImageFlag,
|
||||
internal.CapsulSSHKeysFlag,
|
||||
internal.CapsulAPITokenFlag,
|
||||
|
||||
// Hetzner
|
||||
internal.HetznerCloudNameFlag,
|
||||
internal.HetznerCloudTypeFlag,
|
||||
internal.HetznerCloudImageFlag,
|
||||
internal.HetznerCloudSSHKeysFlag,
|
||||
internal.HetznerCloudLocationFlag,
|
||||
internal.HetznerCloudAPITokenFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if err := internal.EnsureServerProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
switch internal.ServerProvider {
|
||||
case "capsul":
|
||||
if err := newCapsulVPS(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
case "hetzner-cloud":
|
||||
if err := newHetznerCloudVPS(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
102
cli/server/prune.go
Normal file
102
cli/server/prune.go
Normal file
@ -0,0 +1,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var ServerPruneCommand = &cobra.Command{
|
||||
Use: "prune <server> [flags]",
|
||||
Aliases: []string{"p"},
|
||||
Short: "Prune resources on a server",
|
||||
Long: `Prunes unused containers, networks, and dangling images.
|
||||
|
||||
Use "--volumes/-v" to remove volumes that are not associated with a deployed
|
||||
app. This can result in unwanted data loss if not used carefully.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.ServerNameComplete()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
serverName := internal.ValidateServer(args)
|
||||
|
||||
cl, err := client.New(serverName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var filterArgs filters.Args
|
||||
|
||||
cr, err := cl.ContainersPrune(cmd.Context(), filterArgs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
|
||||
log.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
|
||||
|
||||
nr, err := cl.NetworksPrune(cmd.Context(), filterArgs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Infof("networks pruned: %d", len(nr.NetworksDeleted))
|
||||
|
||||
pruneFilters := filters.NewArgs()
|
||||
if allFilter {
|
||||
log.Debugf("removing all images, not only dangling ones")
|
||||
pruneFilters.Add("dangling", "false")
|
||||
}
|
||||
|
||||
ir, err := cl.ImagesPrune(cmd.Context(), pruneFilters)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
|
||||
log.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
|
||||
|
||||
if volumesFilter {
|
||||
vr, err := cl.VolumesPrune(cmd.Context(), filterArgs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
volSpaceReclaimed := formatter.ByteCountSI(vr.SpaceReclaimed)
|
||||
log.Infof("volumes pruned: %d; space reclaimed: %s", len(vr.VolumesDeleted), volSpaceReclaimed)
|
||||
}
|
||||
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
allFilter bool
|
||||
volumesFilter bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
ServerPruneCommand.Flags().BoolVarP(
|
||||
&allFilter,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"remove all unused images",
|
||||
)
|
||||
|
||||
ServerPruneCommand.Flags().BoolVarP(
|
||||
&volumesFilter,
|
||||
"volumes",
|
||||
"v",
|
||||
false,
|
||||
"remove volumes",
|
||||
)
|
||||
}
|
||||
@ -1,182 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rmServer bool
|
||||
var rmServerFlag = &cli.BoolFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Value: false,
|
||||
Usage: "remove the actual server also",
|
||||
Destination: &rmServer,
|
||||
}
|
||||
var ServerRemoveCommand = &cobra.Command{
|
||||
Use: "remove <server> [flags]",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a managed server",
|
||||
Long: `Remove a managed server.
|
||||
|
||||
func rmHetznerCloudVPS(c *cli.Context) error {
|
||||
if internal.HetznerCloudName == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS name",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.HetznerCloudName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.HetznerCloudAPIToken == "" && !internal.NoInput {
|
||||
token, ok := os.LookupEnv("HCLOUD_TOKEN")
|
||||
if !ok {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud API token",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.HetznerCloudAPIToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
internal.HetznerCloudAPIToken = token
|
||||
}
|
||||
}
|
||||
|
||||
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
|
||||
|
||||
server, _, err := client.Server.Get(c.Context, internal.HetznerCloudName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if server == nil {
|
||||
logrus.Fatalf("library provider reports that %s doesn't exist?", internal.HetznerCloudName)
|
||||
}
|
||||
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
You have requested that Abra delete the following server (%s). Please be
|
||||
absolutely sure that this is indeed the server that you would like to have
|
||||
removed. There will be no going back once you confirm, the server will be
|
||||
destroyed.
|
||||
`, server.Name))
|
||||
|
||||
tableColumns := []string{"name", "type", "image", "location"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
table.Append([]string{
|
||||
server.Name,
|
||||
server.ServerType.Name,
|
||||
server.Image.Name,
|
||||
server.Datacenter.Name,
|
||||
})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with hetzner cloud VPS removal?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
_, err = client.Server.Delete(c.Context, server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Infof("%s has been deleted from your hetzner cloud account", internal.HetznerCloudName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var serverRemoveCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
ArgsUsage: "[<server>]",
|
||||
Usage: "Remove a managed server",
|
||||
Description: `
|
||||
This command removes a server from Abra management.
|
||||
|
||||
Depending on whether you used a 3rd party provider to create this server ("abra
|
||||
server new"), you can also destroy the virtual server as well. Pass
|
||||
"--server/-s" to tell Abra to try to delete this VPS.
|
||||
|
||||
Otherwise, Abra will remove the internal bookkeeping (~/.abra/servers/...) and
|
||||
Abra will remove the internal bookkeeping ($ABRA_DIR/servers/...) and
|
||||
underlying client connection context. This server will then be lost in time,
|
||||
like tears in rain.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
rmServerFlag,
|
||||
internal.ServerProviderFlag,
|
||||
|
||||
// Hetzner
|
||||
internal.HetznerCloudNameFlag,
|
||||
internal.HetznerCloudAPITokenFlag,
|
||||
like tears in rain.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command,
|
||||
args []string,
|
||||
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return autocomplete.ServerNameComplete()
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
serverName := c.Args().Get(1)
|
||||
if serverName != "" {
|
||||
var err error
|
||||
serverName, err = internal.ValidateServer(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
serverName := internal.ValidateServer(args)
|
||||
|
||||
if err := client.DeleteContext(serverName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !rmServer {
|
||||
logrus.Warn("did not pass -s/--server for actual server deletion, prompting")
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "prompt to actual server deletion?",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if response {
|
||||
logrus.Info("setting -s/--server and attempting to remove actual server")
|
||||
rmServer = true
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if rmServer {
|
||||
if err := internal.EnsureServerProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
log.Infof("%s is now lost in time, like tears in rain", serverName)
|
||||
|
||||
switch internal.ServerProvider {
|
||||
case "capsul":
|
||||
logrus.Warn("capsul provider does not support automatic removal yet, sorry!")
|
||||
case "hetzner-cloud":
|
||||
if err := rmHetznerCloudVPS(c); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if serverName != "" {
|
||||
if err := client.DeleteContext(serverName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName)
|
||||
}
|
||||
|
||||
return nil
|
||||
return
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,27 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// ServerCommand defines the `abra server` command and its subcommands
|
||||
var ServerCommand = &cli.Command{
|
||||
Name: "server",
|
||||
var ServerCommand = &cobra.Command{
|
||||
Use: "server [cmd] [args] [flags]",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Manage servers",
|
||||
Description: `
|
||||
These commands support creating, managing and removing servers using 3rd party
|
||||
integrations.
|
||||
|
||||
Servers can be created from scratch using the "abra server new" command. If you
|
||||
already have a server, you can add it to your configuration using "abra server
|
||||
add". Abra can provision servers so that they are ready to deploy Co-op Cloud
|
||||
apps, see available flags on "server add" for more.
|
||||
`,
|
||||
Subcommands: []*cli.Command{
|
||||
serverNewCommand,
|
||||
serverAddCommand,
|
||||
serverListCommand,
|
||||
serverRemoveCommand,
|
||||
},
|
||||
Short: "Manage servers",
|
||||
}
|
||||
|
||||
550
cli/updater/updater.go
Normal file
550
cli/updater/updater.go
Normal file
@ -0,0 +1,550 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/tagcmp"
|
||||
charmLog "github.com/charmbracelet/log"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
)
|
||||
|
||||
const SERVER = "localhost"
|
||||
|
||||
// NotifyCommand checks for available upgrades.
|
||||
var NotifyCommand = &cobra.Command{
|
||||
Use: "notify [flags]",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Check for available upgrades",
|
||||
Long: `Notify on new versions for deployed apps.
|
||||
|
||||
If a new patch/minor version is available, a notification is printed.
|
||||
|
||||
Use "--major/-m" to include new major versions.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cl, err := client.New("default")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
stacks, err := stack.GetStacks(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, stackInfo := range stacks {
|
||||
stackName := stackInfo.Name
|
||||
recipeName, err := getLabel(cl, stackName, "recipe")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if recipeName != "" {
|
||||
_, err = getLatestUpgrade(cl, stackName, recipeName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// UpgradeCommand upgrades apps.
|
||||
var UpgradeCommand = &cobra.Command{
|
||||
Use: "upgrade [[stack] [recipe] | --all] [flags]",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Upgrade apps",
|
||||
Long: `Upgrade an app by specifying stack name and recipe.
|
||||
|
||||
Use "--all" to upgrade every deployed app.
|
||||
|
||||
For each app with auto updates enabled, the deployed version is compared with
|
||||
the current recipe catalogue version. If a new patch/minor version is
|
||||
available, the app is upgraded.
|
||||
|
||||
To include major versions use the "--major/-m" flag. You probably don't want
|
||||
that as it will break things. Only apps that are not deployed with "--chaos/-C"
|
||||
are upgraded, to update chaos deployments use the "--chaos/-C" flag. Use it
|
||||
with care.`,
|
||||
Args: cobra.RangeArgs(0, 2),
|
||||
// TODO(d1): complete stack/recipe
|
||||
// ValidArgsFunction: func(
|
||||
// cmd *cobra.Command,
|
||||
// args []string,
|
||||
// toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// },
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cl, err := client.New("default")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !updateAll && len(args) != 2 {
|
||||
log.Fatal("missing arguments or --all/-a flag")
|
||||
}
|
||||
|
||||
if !updateAll {
|
||||
stackName := args[0]
|
||||
recipeName := args[1]
|
||||
|
||||
err = tryUpgrade(cl, stackName, recipeName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stacks, err := stack.GetStacks(cl)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, stackInfo := range stacks {
|
||||
stackName := stackInfo.Name
|
||||
recipeName, err := getLabel(cl, stackName, "recipe")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = tryUpgrade(cl, stackName, recipeName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// getLabel reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}".
|
||||
func getLabel(cl *dockerclient.Client, stackName string, label string) (string, error) {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
|
||||
|
||||
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
|
||||
if labelValue, ok := service.Spec.Labels[labelKey]; ok {
|
||||
return labelValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("no %s label found for %s", label, stackName)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getBoolLabel reads a boolean docker label from running services
|
||||
func getBoolLabel(cl *dockerclient.Client, stackName string, label string) (bool, error) {
|
||||
lableValue, err := getLabel(cl, stackName, label)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if lableValue != "" {
|
||||
value, err := strconv.ParseBool(lableValue)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
log.Debugf("boolean label %s could not be found for %s, set default to false.", label, stackName)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// getEnv reads env variables from docker services.
|
||||
func getEnv(cl *dockerclient.Client, stackName string) (envfile.AppEnv, error) {
|
||||
envMap := make(map[string]string)
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
|
||||
|
||||
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
envList := service.Spec.TaskTemplate.ContainerSpec.Env
|
||||
for _, envString := range envList {
|
||||
splitString := strings.SplitN(envString, "=", 2)
|
||||
if len(splitString) != 2 {
|
||||
log.Debugf("can't separate key from value: %s (this variable is probably unset)", envString)
|
||||
continue
|
||||
}
|
||||
k := splitString[0]
|
||||
v := splitString[1]
|
||||
log.Debugf("for %s read env %s with value: %s from docker service", stackName, k, v)
|
||||
envMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return envMap, nil
|
||||
}
|
||||
|
||||
// getLatestUpgrade returns the latest available version for an app respecting
|
||||
// the "--major" flag if it is newer than the currently deployed version.
|
||||
func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName string) (string, error) {
|
||||
deployedVersion, err := getDeployedVersion(cl, stackName, recipeName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
availableUpgrades, err := getAvailableUpgrades(cl, stackName, recipeName, deployedVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(availableUpgrades) == 0 {
|
||||
log.Debugf("no available upgrades for %s", stackName)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var chosenUpgrade string
|
||||
if len(availableUpgrades) > 0 {
|
||||
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
|
||||
log.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade)
|
||||
}
|
||||
|
||||
return chosenUpgrade, nil
|
||||
}
|
||||
|
||||
// getDeployedVersion returns the currently deployed version of an app.
|
||||
func getDeployedVersion(cl *dockerclient.Client, stackName string, recipeName string) (string, error) {
|
||||
log.Debugf("retrieve deployed version whether %s is already deployed", stackName)
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
return "", fmt.Errorf("%s is not deployed?", stackName)
|
||||
}
|
||||
|
||||
if deployMeta.Version == "unknown" {
|
||||
return "", fmt.Errorf("failed to determine deployed version of %s", stackName)
|
||||
}
|
||||
|
||||
return deployMeta.Version, nil
|
||||
}
|
||||
|
||||
// getAvailableUpgrades returns all available versions of an app that are newer
|
||||
// than the deployed version. It only includes major upgrades if the "--major"
|
||||
// flag is set.
|
||||
func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName string,
|
||||
deployedVersion string) ([]string, error) {
|
||||
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(recipeName, catl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
log.Warnf("no published releases for %s in the recipe catalogue?", recipeName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var availableUpgrades []string
|
||||
for _, version := range versions {
|
||||
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsedVersion, err := tagcmp.Parse(version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
versionDelta, err := parsedDeployedVersion.UpgradeDelta(parsedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if 0 < versionDelta.UpgradeType() && (versionDelta.UpgradeType() < 4 || includeMajorUpdates) {
|
||||
availableUpgrades = append(availableUpgrades, version)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("available updates for %s: %s", stackName, availableUpgrades)
|
||||
|
||||
return availableUpgrades, nil
|
||||
}
|
||||
|
||||
// processRecipeRepoVersion clones, pulls, checks out the version and lints the
|
||||
// recipe repository.
|
||||
func processRecipeRepoVersion(r recipe.Recipe, version string) error {
|
||||
if err := r.EnsureExists(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.EnsureUpToDate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := r.EnsureVersion(version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAbraShEnv merges abra.sh env vars into the app env vars.
|
||||
func mergeAbraShEnv(recipe recipe.Recipe, env envfile.AppEnv) error {
|
||||
abraShEnv, err := envfile.ReadAbraShEnvVars(recipe.AbraShPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, v := range abraShEnv {
|
||||
log.Debugf("read v:%s k: %s", v, k)
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDeployConfig merges and enriches the compose config for the deployment.
|
||||
func createDeployConfig(r recipe.Recipe, stackName string, env envfile.AppEnv) (*composetypes.Config, stack.Deploy, error) {
|
||||
env["STACK_NAME"] = stackName
|
||||
|
||||
deployOpts := stack.Deploy{
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
}
|
||||
|
||||
composeFiles, err := r.GetComposeFiles(env)
|
||||
if err != nil {
|
||||
return nil, deployOpts, err
|
||||
}
|
||||
|
||||
deployOpts.Composefiles = composeFiles
|
||||
compose, err := appPkg.GetAppComposeConfig(stackName, deployOpts, env)
|
||||
if err != nil {
|
||||
return nil, deployOpts, err
|
||||
}
|
||||
|
||||
appPkg.ExposeAllEnv(stackName, compose, env)
|
||||
|
||||
// after the upgrade the deployment won't be in chaos state anymore
|
||||
appPkg.SetChaosLabel(compose, stackName, false)
|
||||
appPkg.SetRecipeLabel(compose, stackName, r.Name)
|
||||
appPkg.SetUpdateLabel(compose, stackName, env)
|
||||
|
||||
return compose, deployOpts, nil
|
||||
}
|
||||
|
||||
// tryUpgrade performs the upgrade if all the requirements are fulfilled.
|
||||
func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
|
||||
if recipeName == "" {
|
||||
log.Debugf("don't update %s due to missing recipe name", stackName)
|
||||
return nil
|
||||
}
|
||||
|
||||
chaos, err := getBoolLabel(cl, stackName, "chaos")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chaos && !internal.Chaos {
|
||||
log.Debugf("don't update %s due to chaos deployment", stackName)
|
||||
return nil
|
||||
}
|
||||
|
||||
updatesEnabled, err := getBoolLabel(cl, stackName, "autoupdate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updatesEnabled {
|
||||
log.Debugf("don't update %s due to disabled auto updates or missing ENABLE_AUTO_UPDATE env", stackName)
|
||||
return nil
|
||||
}
|
||||
|
||||
upgradeVersion, err := getLatestUpgrade(cl, stackName, recipeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if upgradeVersion == "" {
|
||||
log.Debugf("don't update %s due to no new version", stackName)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = upgrade(cl, stackName, recipeName, upgradeVersion)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// upgrade performs all necessary steps to upgrade an app.
|
||||
func upgrade(cl *dockerclient.Client, stackName, recipeName, upgradeVersion string) error {
|
||||
env, err := getEnv(cl, stackName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := appPkg.App{
|
||||
Name: stackName,
|
||||
Recipe: recipe.Get(recipeName),
|
||||
Server: SERVER,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
r := recipe.Get(recipeName)
|
||||
|
||||
if err = processRecipeRepoVersion(r, upgradeVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = mergeAbraShEnv(app.Recipe, app.Env); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compose, deployOpts, err := createDeployConfig(r, stackName, app.Env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)
|
||||
|
||||
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := app.Filters(true, false, serviceNames...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = stack.RunDeploy(
|
||||
cl,
|
||||
deployOpts,
|
||||
compose,
|
||||
stackName,
|
||||
app.Server,
|
||||
true,
|
||||
f,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func newKadabraApp(version, commit string) *cobra.Command {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "kadabra [cmd] [flags]",
|
||||
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
|
||||
Short: "The Co-op Cloud auto-updater 🤖 🚀",
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.Logger.SetStyles(charmLog.DefaultStyles())
|
||||
charmLog.SetDefault(log.Logger)
|
||||
|
||||
if internal.Debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetReportCaller(true)
|
||||
}
|
||||
|
||||
log.Debugf("kadabra version %s, commit %s", version, commit)
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.Debug, "debug", "d", false,
|
||||
"show debug messages",
|
||||
)
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&internal.NoInput, "no-input", "n", false,
|
||||
"toggle non-interactive mode",
|
||||
)
|
||||
|
||||
rootCmd.AddCommand(
|
||||
NotifyCommand,
|
||||
UpgradeCommand,
|
||||
)
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// RunApp runs CLI abra app.
|
||||
func RunApp(version, commit string) {
|
||||
app := newKadabraApp(version, commit)
|
||||
|
||||
if err := app.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
includeMajorUpdates bool
|
||||
updateAll bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
NotifyCommand.Flags().BoolVarP(
|
||||
&includeMajorUpdates,
|
||||
"major",
|
||||
"m",
|
||||
false,
|
||||
"check for major updates",
|
||||
)
|
||||
|
||||
UpgradeCommand.Flags().BoolVarP(
|
||||
&internal.Chaos,
|
||||
"chaos",
|
||||
"C",
|
||||
false,
|
||||
"ignore uncommitted recipes changes",
|
||||
)
|
||||
|
||||
UpgradeCommand.Flags().BoolVarP(
|
||||
&includeMajorUpdates,
|
||||
"major",
|
||||
"m",
|
||||
false,
|
||||
"check for major updates",
|
||||
)
|
||||
|
||||
UpgradeCommand.Flags().BoolVarP(
|
||||
&updateAll,
|
||||
"all",
|
||||
"a",
|
||||
false,
|
||||
"update all deployed apps",
|
||||
)
|
||||
}
|
||||
56
cli/upgrade.go
Normal file
56
cli/upgrade.go
Normal file
@ -0,0 +1,56 @@
|
||||
// Package cli provides the interface for the command-line.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// UpgradeCommand upgrades abra in-place.
|
||||
var UpgradeCommand = &cobra.Command{
|
||||
Use: "upgrade [flags]",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Upgrade abra",
|
||||
Long: `Upgrade abra in-place with the latest stable or release candidate.
|
||||
|
||||
By default, the latest stable release is downloaded.
|
||||
|
||||
Use "--rc/-r" to install the latest release candidate. Please bear in mind that
|
||||
it may contain absolutely catastrophic deal-breaker bugs. Thank you very much
|
||||
for the testing efforts 💗`,
|
||||
Example: " abra upgrade --rc",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
mainURL := "https://install.abra.coopcloud.tech"
|
||||
c := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
|
||||
|
||||
if releaseCandidate {
|
||||
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
|
||||
c = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
|
||||
}
|
||||
|
||||
log.Debugf("attempting to run %s", c)
|
||||
|
||||
if err := internal.RunCmd(c); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
releaseCandidate bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
UpgradeCommand.Flags().BoolVarP(
|
||||
&releaseCandidate,
|
||||
"rc",
|
||||
"r",
|
||||
false,
|
||||
"install release candidate (may contain bugs)",
|
||||
)
|
||||
}
|
||||
@ -5,10 +5,10 @@ import (
|
||||
"coopcloud.tech/abra/cli"
|
||||
)
|
||||
|
||||
// Version is the current version of Abra
|
||||
// Version is the current version of Abra.
|
||||
var Version string
|
||||
|
||||
// Commit is the current git commit of Abra
|
||||
// Commit is the current git commit of Abra.
|
||||
var Commit string
|
||||
|
||||
func main() {
|
||||
@ -19,5 +19,5 @@ func main() {
|
||||
Commit = " "
|
||||
}
|
||||
|
||||
cli.RunApp(Version, Commit)
|
||||
cli.Run(Version, Commit)
|
||||
}
|
||||
|
||||
23
cmd/kadabra/main.go
Normal file
23
cmd/kadabra/main.go
Normal file
@ -0,0 +1,23 @@
|
||||
// Package main provides the command-line entrypoint.
|
||||
package main
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/updater"
|
||||
)
|
||||
|
||||
// Version is the current version of Kadabra.
|
||||
var Version string
|
||||
|
||||
// Commit is the current git commit of Kadabra.
|
||||
var Commit string
|
||||
|
||||
func main() {
|
||||
if Version == "" {
|
||||
Version = "dev"
|
||||
}
|
||||
if Commit == "" {
|
||||
Commit = " "
|
||||
}
|
||||
|
||||
updater.RunApp(Version, Commit)
|
||||
}
|
||||
190
go.mod
190
go.mod
@ -1,49 +1,163 @@
|
||||
module coopcloud.tech/abra
|
||||
|
||||
go 1.16
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
|
||||
github.com/AlecAivazis/survey/v2 v2.3.2
|
||||
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
|
||||
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
|
||||
github.com/docker/cli v20.10.12+incompatible
|
||||
github.com/docker/distribution v2.7.1+incompatible
|
||||
github.com/docker/docker v20.10.12+incompatible
|
||||
github.com/docker/go-units v0.4.0
|
||||
github.com/go-git/go-git/v5 v5.4.2
|
||||
github.com/hetznercloud/hcloud-go v1.33.1
|
||||
github.com/moby/sys/signal v0.6.0
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca
|
||||
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v28.3.3+incompatible
|
||||
github.com/docker/docker v28.3.3+incompatible
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/leonelquinteros/gotext v1.7.2
|
||||
github.com/moby/sys/signal v0.7.1
|
||||
github.com/moby/term v0.5.2
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/schollz/progressbar/v3 v3.8.5
|
||||
github.com/schultz-is/passgen v1.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
gotest.tools/v3 v3.0.3
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
golang.org/x/term v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gotest.tools/v3 v3.5.2
|
||||
)
|
||||
|
||||
require (
|
||||
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
|
||||
github.com/Microsoft/hcsshim v0.8.21 // indirect
|
||||
github.com/buger/goterm v1.0.3
|
||||
github.com/containerd/containerd v1.5.5 // indirect
|
||||
github.com/docker/docker-credential-helpers v0.6.4 // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/fvbommel/sortorder v1.0.2 // indirect
|
||||
github.com/gliderlabs/ssh v0.3.3
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.0
|
||||
github.com/kevinburke/ssh_config v1.1.0
|
||||
github.com/libdns/gandi v1.0.2
|
||||
github.com/libdns/libdns v0.2.1
|
||||
github.com/moby/sys/mount v0.2.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/ghodss/yaml v1.0.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/runc v1.0.2 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/runc v1.1.13 // indirect
|
||||
github.com/opencontainers/runtime-spec v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pjbgf/sha1cd v0.4.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/containers/image v3.0.2+incompatible
|
||||
github.com/containers/storage v1.38.2 // indirect
|
||||
github.com/decentral1se/passgen v1.0.1
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
golang.org/x/sys v0.35.0
|
||||
)
|
||||
|
||||
12
locales/default.pot
Normal file
12
locales/default.pot
Normal file
@ -0,0 +1,12 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: \n"
|
||||
"X-Generator: xgotext\n"
|
||||
|
||||
#: app.go:11
|
||||
msgid "Manage apps"
|
||||
msgstr ""
|
||||
20
locales/es/LC_MESSAGES/default.po
Normal file
20
locales/es/LC_MESSAGES/default.po
Normal file
@ -0,0 +1,20 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-08-04 14:15+0000\n"
|
||||
"PO-Revision-Date: 2025-08-04 14:15+0000\n"
|
||||
"Last-Translator: 3wordchant <3wc.coopcloud@doesthisthing.work>\n"
|
||||
"Language-Team: Spanish <https://translate.coopcloud.tech/projects/"
|
||||
"co-op-cloud/abra/es/>\n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: ENCODING\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 5.12.2\n"
|
||||
|
||||
#: app.go:11
|
||||
msgid "Manage apps"
|
||||
msgstr "Gestionar aplicaciones"
|
||||
704
pkg/app/app.go
704
pkg/app/app.go
@ -1,85 +1,687 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
apiclient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
loader "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
)
|
||||
|
||||
// Get retrieves an app
|
||||
func Get(appName string) (config.App, error) {
|
||||
files, err := config.LoadAppFiles("")
|
||||
func Get(appName string) (App, error) {
|
||||
files, err := LoadAppFiles("")
|
||||
if err != nil {
|
||||
return config.App{}, err
|
||||
return App{}, err
|
||||
}
|
||||
|
||||
app, err := config.GetApp(files, appName)
|
||||
app, err := GetApp(files, appName)
|
||||
if err != nil {
|
||||
return config.App{}, err
|
||||
return App{}, err
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved %s for %s", app, appName)
|
||||
log.Debugf("loaded app %s: %s", appName, app)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// deployedServiceSpec represents a deployed service of an app.
|
||||
type deployedServiceSpec struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
// VersionSpec represents a deployed app and associated metadata.
|
||||
type VersionSpec map[string]deployedServiceSpec
|
||||
|
||||
// DeployedVersions lists metadata (e.g. versions) for deployed
|
||||
func DeployedVersions(ctx context.Context, cl *apiclient.Client, app config.App) (VersionSpec, bool, error) {
|
||||
services, err := stack.GetStackServices(ctx, cl, app.StackName())
|
||||
if err != nil {
|
||||
return VersionSpec{}, false, err
|
||||
// GetApp loads an apps settings, reading it from file, in preparation to use
|
||||
// it. It should only be used when ready to use the env file to keep IO
|
||||
// operations down.
|
||||
func GetApp(apps AppFiles, name AppName) (App, error) {
|
||||
appFile, exists := apps[name]
|
||||
if !exists {
|
||||
return App{}, fmt.Errorf("cannot find app with name %s", name)
|
||||
}
|
||||
|
||||
appSpec := make(VersionSpec)
|
||||
for _, service := range services {
|
||||
serviceName := ParseServiceName(service.Spec.Name)
|
||||
label := fmt.Sprintf("coop-cloud.%s.%s.version", app.StackName(), serviceName)
|
||||
if deployLabel, ok := service.Spec.Labels[label]; ok {
|
||||
version, _ := ParseVersionLabel(deployLabel)
|
||||
appSpec[serviceName] = deployedServiceSpec{Name: serviceName, Version: version}
|
||||
app, err := ReadAppEnvFile(appFile, name)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// GetApps returns a slice of Apps with their env files read from a given
|
||||
// slice of AppFiles.
|
||||
func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
|
||||
var apps []App
|
||||
|
||||
for name := range appFiles {
|
||||
app, err := GetApp(appFiles, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if recipeFilter != "" {
|
||||
if app.Recipe.Name == recipeFilter {
|
||||
apps = append(apps, app)
|
||||
}
|
||||
} else {
|
||||
apps = append(apps, app)
|
||||
}
|
||||
}
|
||||
|
||||
deployed := len(services) > 0
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
if deployed {
|
||||
logrus.Debugf("detected %s as deployed versions of %s", appSpec, app.Name)
|
||||
} else {
|
||||
logrus.Debugf("detected %s as not deployed", app.Name)
|
||||
// App reprents an app with its env file read into memory
|
||||
type App struct {
|
||||
Name AppName
|
||||
Recipe recipe.Recipe
|
||||
Domain string
|
||||
Env envfile.AppEnv
|
||||
Server string
|
||||
Path string
|
||||
}
|
||||
|
||||
// String outputs a human-friendly string representation.
|
||||
func (a App) String() string {
|
||||
out := fmt.Sprintf("{name: %s, ", a.Name)
|
||||
out += fmt.Sprintf("recipe: %s, ", a.Recipe)
|
||||
out += fmt.Sprintf("domain: %s, ", a.Domain)
|
||||
out += fmt.Sprintf("env %s, ", a.Env)
|
||||
out += fmt.Sprintf("server %s, ", a.Server)
|
||||
out += fmt.Sprintf("path %s}", a.Path)
|
||||
return out
|
||||
}
|
||||
|
||||
// Type aliases to make code hints easier to understand
|
||||
|
||||
// AppName is AppName
|
||||
type AppName = string
|
||||
|
||||
// AppFile represents app env files on disk without reading the contents
|
||||
type AppFile struct {
|
||||
Path string
|
||||
Server string
|
||||
}
|
||||
|
||||
// AppFiles is a slice of appfiles
|
||||
type AppFiles map[AppName]AppFile
|
||||
|
||||
// See documentation of config.StackName
|
||||
func (a App) StackName() string {
|
||||
if _, exists := a.Env["STACK_NAME"]; exists {
|
||||
return a.Env["STACK_NAME"]
|
||||
}
|
||||
|
||||
return appSpec, len(services) > 0, nil
|
||||
stackName := StackName(a.Name)
|
||||
|
||||
a.Env["STACK_NAME"] = stackName
|
||||
|
||||
return stackName
|
||||
}
|
||||
|
||||
// ParseVersionLabel parses a $VERSION-$DIGEST app service label.
|
||||
func ParseVersionLabel(label string) (string, string) {
|
||||
// versions may look like v4.2-abcd or v4.2-alpine-abcd
|
||||
idx := strings.LastIndex(label, "-")
|
||||
version := label[:idx]
|
||||
digest := label[idx+1:]
|
||||
logrus.Debugf("parsed %s as version from %s", version, label)
|
||||
logrus.Debugf("parsed %s as digest from %s", digest, label)
|
||||
return version, digest
|
||||
// StackName gets whatever the docker safe (uses the right delimiting
|
||||
// character, e.g. "_") stack name is for the app. In general, you don't want
|
||||
// to use this to show anything to end-users, you want use a.Name instead.
|
||||
func StackName(appName string) string {
|
||||
stackName := SanitiseAppName(appName)
|
||||
|
||||
if len(stackName) > config.MAX_SANITISED_APP_NAME_LENGTH {
|
||||
log.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:config.MAX_SANITISED_APP_NAME_LENGTH])
|
||||
stackName = stackName[:config.MAX_SANITISED_APP_NAME_LENGTH]
|
||||
}
|
||||
|
||||
return stackName
|
||||
}
|
||||
|
||||
// ParseServiceName parses a $STACK_NAME_$SERVICE_NAME service label.
|
||||
func ParseServiceName(label string) string {
|
||||
idx := strings.LastIndex(label, "_")
|
||||
serviceName := label[idx+1:]
|
||||
logrus.Debugf("parsed %s as service name from %s", serviceName, label)
|
||||
return serviceName
|
||||
// Filters retrieves app filters for querying the container runtime. By default
|
||||
// it filters on all services in the app. It is also possible to pass an
|
||||
// otional list of service names, which get filtered instead.
|
||||
//
|
||||
// Due to upstream issues, filtering works different depending on what you're
|
||||
// querying. So, for example, secrets don't work with regex! The caller needs
|
||||
// to implement their own validation that the right secrets are matched. In
|
||||
// order to handle these cases, we provide the `appendServiceNames` /
|
||||
// `exactMatch` modifiers.
|
||||
func (a App) Filters(appendServiceNames, exactMatch bool, services ...string) (filters.Args, error) {
|
||||
filters := filters.NewArgs()
|
||||
if len(services) > 0 {
|
||||
for _, serviceName := range services {
|
||||
filters.Add("name", ServiceFilter(a.StackName(), serviceName, exactMatch))
|
||||
}
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// When not appending the service name, just add one filter for the whole
|
||||
// stack.
|
||||
if !appendServiceNames {
|
||||
f := fmt.Sprintf("%s", a.StackName())
|
||||
if exactMatch {
|
||||
f = fmt.Sprintf("^%s", f)
|
||||
}
|
||||
filters.Add("name", f)
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
composeFiles, err := a.Recipe.GetComposeFiles(a.Env)
|
||||
if err != nil {
|
||||
return filters, err
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles}
|
||||
compose, err := GetAppComposeConfig(a.Recipe.Name, opts, a.Env)
|
||||
if err != nil {
|
||||
return filters, err
|
||||
}
|
||||
|
||||
for _, service := range compose.Services {
|
||||
f := ServiceFilter(a.StackName(), service.Name, exactMatch)
|
||||
filters.Add("name", f)
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// ServiceFilter creates a filter string for filtering a service in the docker
|
||||
// container runtime. When exact match is true, it uses regex to match the
|
||||
// string exactly.
|
||||
func ServiceFilter(stack, service string, exact bool) string {
|
||||
if exact {
|
||||
return fmt.Sprintf("^%s_%s", stack, service)
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", stack, service)
|
||||
}
|
||||
|
||||
// ByServer sort a slice of Apps
|
||||
type ByServer []App
|
||||
|
||||
func (a ByServer) Len() int { return len(a) }
|
||||
func (a ByServer) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByServer) Less(i, j int) bool {
|
||||
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
|
||||
}
|
||||
|
||||
// ByServerAndRecipe sort a slice of Apps
|
||||
type ByServerAndRecipe []App
|
||||
|
||||
func (a ByServerAndRecipe) Len() int { return len(a) }
|
||||
func (a ByServerAndRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByServerAndRecipe) Less(i, j int) bool {
|
||||
if a[i].Server == a[j].Server {
|
||||
return strings.ToLower(a[i].Recipe.Name) < strings.ToLower(a[j].Recipe.Name)
|
||||
}
|
||||
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
|
||||
}
|
||||
|
||||
// ByRecipe sort a slice of Apps
|
||||
type ByRecipe []App
|
||||
|
||||
func (a ByRecipe) Len() int { return len(a) }
|
||||
func (a ByRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByRecipe) Less(i, j int) bool {
|
||||
return strings.ToLower(a[i].Recipe.Name) < strings.ToLower(a[j].Recipe.Name)
|
||||
}
|
||||
|
||||
// ByName sort a slice of Apps
|
||||
type ByName []App
|
||||
|
||||
func (a ByName) Len() int { return len(a) }
|
||||
func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByName) Less(i, j int) bool {
|
||||
return strings.ToLower(a[i].Name) < strings.ToLower(a[j].Name)
|
||||
}
|
||||
|
||||
func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
|
||||
env, err := envfile.ReadEnv(appFile.Path)
|
||||
if err != nil {
|
||||
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error())
|
||||
}
|
||||
|
||||
app, err := NewApp(env, name, appFile)
|
||||
if err != nil {
|
||||
return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error())
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// NewApp creates new App object
|
||||
func NewApp(env envfile.AppEnv, name string, appFile AppFile) (App, error) {
|
||||
domain := env["DOMAIN"]
|
||||
|
||||
recipeName, exists := env["RECIPE"]
|
||||
if !exists {
|
||||
recipeName, exists = env["TYPE"]
|
||||
if !exists {
|
||||
return App{}, fmt.Errorf("%s is missing the TYPE env var?", name)
|
||||
}
|
||||
}
|
||||
|
||||
return App{
|
||||
Name: name,
|
||||
Domain: domain,
|
||||
Recipe: recipe.Get(recipeName),
|
||||
Env: env,
|
||||
Server: appFile.Server,
|
||||
Path: appFile.Path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadAppFiles gets all app files for a given set of servers or all servers.
|
||||
func LoadAppFiles(servers ...string) (AppFiles, error) {
|
||||
appFiles := make(AppFiles)
|
||||
if len(servers) == 1 {
|
||||
if servers[0] == "" {
|
||||
// Empty servers flag, one string will always be passed
|
||||
var err error
|
||||
servers, err = config.GetAllFoldersInDirectory(config.SERVERS_DIR)
|
||||
if err != nil {
|
||||
return appFiles, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", "))
|
||||
|
||||
for _, server := range servers {
|
||||
serverDir := path.Join(config.SERVERS_DIR, server)
|
||||
files, err := config.GetAllFilesInDirectory(serverDir)
|
||||
if err != nil {
|
||||
return appFiles, fmt.Errorf("server %s doesn't exist? Run \"abra server ls\" to check", server)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
appName := strings.TrimSuffix(file.Name(), ".env")
|
||||
appFilePath := path.Join(config.SERVERS_DIR, server, file.Name())
|
||||
appFiles[appName] = AppFile{
|
||||
Path: appFilePath,
|
||||
Server: server,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appFiles, nil
|
||||
}
|
||||
|
||||
// GetAppServiceNames retrieves a list of app service names.
|
||||
func GetAppServiceNames(appName string) ([]string, error) {
|
||||
var serviceNames []string
|
||||
|
||||
appFiles, err := LoadAppFiles("")
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
app, err := GetApp(appFiles, appName)
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles}
|
||||
compose, err := GetAppComposeConfig(app.Recipe.Name, opts, app.Env)
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
for _, service := range compose.Services {
|
||||
serviceNames = append(serviceNames, service.Name)
|
||||
}
|
||||
|
||||
return serviceNames, nil
|
||||
}
|
||||
|
||||
// GetAppNames retrieves a list of app names.
|
||||
func GetAppNames() ([]string, error) {
|
||||
var appNames []string
|
||||
|
||||
appFiles, err := LoadAppFiles("")
|
||||
if err != nil {
|
||||
return appNames, err
|
||||
}
|
||||
|
||||
apps, err := GetApps(appFiles, "")
|
||||
if err != nil {
|
||||
return appNames, err
|
||||
}
|
||||
|
||||
for _, app := range apps {
|
||||
appNames = append(appNames, app.Name)
|
||||
}
|
||||
|
||||
return appNames, nil
|
||||
}
|
||||
|
||||
// TemplateAppEnvSample copies the example env file for the app into the users
|
||||
// env files.
|
||||
func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error {
|
||||
envSample, err := os.ReadFile(r.SampleEnvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appEnvPath := path.Join(config.ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
|
||||
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s already exists?", appEnvPath)
|
||||
}
|
||||
|
||||
err = os.WriteFile(appEnvPath, envSample, 0o664)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
read, err := os.ReadFile(appEnvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newContents := strings.Replace(string(read), r.Name+".example.com", domain, -1)
|
||||
|
||||
err = os.WriteFile(appEnvPath, []byte(newContents), 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("copied & templated %s to %s", r.SampleEnvPath, appEnvPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitiseAppName makes a app name usable with Docker by replacing illegal
|
||||
// characters.
|
||||
func SanitiseAppName(name string) string {
|
||||
return strings.ReplaceAll(name, ".", "_")
|
||||
}
|
||||
|
||||
// GetAppStatuses queries servers to check the deployment status of given apps.
|
||||
func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]string, error) {
|
||||
statuses := make(map[string]map[string]string)
|
||||
|
||||
servers := make(map[string]struct{})
|
||||
for _, app := range apps {
|
||||
if _, ok := servers[app.Server]; !ok {
|
||||
servers[app.Server] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var bar *progressbar.ProgressBar
|
||||
if !MachineReadable {
|
||||
bar = formatter.CreateProgressbar(len(servers), "querying remote servers...")
|
||||
}
|
||||
|
||||
ch := make(chan stack.StackStatus, len(servers))
|
||||
for server := range servers {
|
||||
cl, err := client.New(server)
|
||||
if err != nil {
|
||||
log.Warn(err)
|
||||
ch <- stack.StackStatus{}
|
||||
continue
|
||||
}
|
||||
|
||||
go func(s string) {
|
||||
ch <- stack.GetAllDeployedServices(cl, s)
|
||||
if !MachineReadable {
|
||||
bar.Add(1)
|
||||
}
|
||||
}(server)
|
||||
}
|
||||
|
||||
for range servers {
|
||||
status := <-ch
|
||||
if status.Err != nil {
|
||||
return statuses, status.Err
|
||||
}
|
||||
|
||||
for _, service := range status.Services {
|
||||
result := make(map[string]string)
|
||||
name := service.Spec.Labels[convert.LabelNamespace]
|
||||
|
||||
if _, ok := statuses[name]; !ok {
|
||||
result["status"] = "deployed"
|
||||
}
|
||||
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", name)
|
||||
chaos, ok := service.Spec.Labels[labelKey]
|
||||
if ok {
|
||||
result["chaos"] = chaos
|
||||
}
|
||||
|
||||
labelKey = fmt.Sprintf("coop-cloud.%s.chaos-version", name)
|
||||
if chaosVersion, ok := service.Spec.Labels[labelKey]; ok {
|
||||
result["chaosVersion"] = chaosVersion
|
||||
}
|
||||
|
||||
labelKey = fmt.Sprintf("coop-cloud.%s.autoupdate", name)
|
||||
if autoUpdate, ok := service.Spec.Labels[labelKey]; ok {
|
||||
result["autoUpdate"] = autoUpdate
|
||||
} else {
|
||||
result["autoUpdate"] = "false"
|
||||
}
|
||||
|
||||
labelKey = fmt.Sprintf("coop-cloud.%s.version", name)
|
||||
if version, ok := service.Spec.Labels[labelKey]; ok {
|
||||
result["version"] = version
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
statuses[name] = result
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("retrieved app statuses: %s", statuses)
|
||||
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
// GetAppComposeConfig retrieves a compose specification for a recipe. This
|
||||
// specification is the result of a merge of all the compose.**.yml files in
|
||||
// the recipe repository.
|
||||
func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv) (*composetypes.Config, error) {
|
||||
compose, err := loader.LoadComposefile(opts, appEnv)
|
||||
if err != nil {
|
||||
return &composetypes.Config{}, err
|
||||
}
|
||||
|
||||
log.Debugf("retrieved %s for %s", compose.Filename, recipe)
|
||||
|
||||
return compose, nil
|
||||
}
|
||||
|
||||
// ExposeAllEnv exposes all env variables to the app container
|
||||
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv envfile.AppEnv) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debugf("adding env vars to %s service config", stackName)
|
||||
for k, v := range appEnv {
|
||||
_, exists := service.Environment[k]
|
||||
if !exists {
|
||||
value := v
|
||||
service.Environment[k] = &value
|
||||
log.Debugf("%s: %s: %s", stackName, k, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CheckEnv(app App) ([]envfile.EnvVar, error) {
|
||||
var envVars []envfile.EnvVar
|
||||
|
||||
envSample, err := app.Recipe.SampleEnv()
|
||||
if err != nil {
|
||||
return envVars, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for key := range envSample {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
if _, ok := app.Env[key]; ok {
|
||||
envVars = append(envVars, envfile.EnvVar{Name: key, Present: true})
|
||||
} else {
|
||||
envVars = append(envVars, envfile.EnvVar{Name: key, Present: false})
|
||||
}
|
||||
}
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
// ReadAbraShCmdNames reads the names of commands.
|
||||
func ReadAbraShCmdNames(abraSh string) ([]string, error) {
|
||||
var cmdNames []string
|
||||
|
||||
file, err := os.Open(abraSh)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cmdNames, nil
|
||||
}
|
||||
return cmdNames, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
cmdNameRegex, err := regexp.Compile(`(\w+)(\(\).*\{)`)
|
||||
if err != nil {
|
||||
return cmdNames, err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
matches := cmdNameRegex.FindStringSubmatch(line)
|
||||
if len(matches) > 0 {
|
||||
cmdNames = append(cmdNames, matches[1])
|
||||
}
|
||||
}
|
||||
|
||||
if len(cmdNames) > 0 {
|
||||
log.Debugf("read %s from %s", strings.Join(cmdNames, " "), abraSh)
|
||||
} else {
|
||||
log.Debugf("read 0 command names from %s", abraSh)
|
||||
}
|
||||
|
||||
return cmdNames, nil
|
||||
}
|
||||
|
||||
// Wipe removes the version from the app .env file.
|
||||
func (a App) WipeRecipeVersion() error {
|
||||
file, err := os.Open(a.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var (
|
||||
lines []string
|
||||
scanner = bufio.NewScanner(file)
|
||||
)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
splitted := strings.Split(line, ":")
|
||||
lines = append(lines, splitted[0])
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Debugf("version wiped from %s.env", a.Domain)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteRecipeVersion writes the recipe version to the app .env file.
|
||||
func (a App) WriteRecipeVersion(version string, dryRun bool) error {
|
||||
file, err := os.Open(a.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var (
|
||||
dirtyVersion string
|
||||
skipped bool
|
||||
lines []string
|
||||
scanner = bufio.NewScanner(file)
|
||||
)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, version) && !a.Recipe.Dirty && !strings.HasSuffix(line, config.DIRTY_DEFAULT) {
|
||||
skipped = true
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
splitted := strings.Split(line, ":")
|
||||
|
||||
line = fmt.Sprintf("%s:%s", splitted[0], version)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if a.Recipe.Dirty && dirtyVersion != "" {
|
||||
version = dirtyVersion
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Debugf("skipping writing version %s because dry run", version)
|
||||
}
|
||||
|
||||
if !skipped {
|
||||
log.Debugf("version %s saved to %s.env", version, a.Domain)
|
||||
} else {
|
||||
log.Debugf("skipping version %s write as already exists in %s.env", version, a.Domain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
226
pkg/app/app_test.go
Normal file
226
pkg/app/app_test.go
Normal file
@ -0,0 +1,226 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
testPkg "coopcloud.tech/abra/pkg/test"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewApp(t *testing.T) {
|
||||
app, err := appPkg.NewApp(testPkg.ExpectedAppEnv, testPkg.AppName, testPkg.ExpectedAppFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
|
||||
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAppEnvFile(t *testing.T) {
|
||||
app, err := appPkg.ReadAppEnvFile(testPkg.ExpectedAppFile, testPkg.AppName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
|
||||
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApp(t *testing.T) {
|
||||
app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
|
||||
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComposeFiles(t *testing.T) {
|
||||
r := recipe.Get("abra-test-recipe")
|
||||
err := r.EnsureExists()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
appEnv map[string]string
|
||||
composeFiles []string
|
||||
}{
|
||||
{
|
||||
map[string]string{},
|
||||
[]string{
|
||||
fmt.Sprintf("%s/compose.yml", r.Dir),
|
||||
},
|
||||
},
|
||||
{
|
||||
map[string]string{"COMPOSE_FILE": "compose.yml"},
|
||||
[]string{
|
||||
fmt.Sprintf("%s/compose.yml", r.Dir),
|
||||
},
|
||||
},
|
||||
{
|
||||
map[string]string{"COMPOSE_FILE": "compose.extra_secret.yml"},
|
||||
[]string{
|
||||
fmt.Sprintf("%s/compose.extra_secret.yml", r.Dir),
|
||||
},
|
||||
},
|
||||
{
|
||||
map[string]string{"COMPOSE_FILE": "compose.yml:compose.extra_secret.yml"},
|
||||
[]string{
|
||||
fmt.Sprintf("%s/compose.yml", r.Dir),
|
||||
fmt.Sprintf("%s/compose.extra_secret.yml", r.Dir),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
composeFiles, err := r.GetComposeFiles(test.appEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, composeFiles, test.composeFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComposeFilesError(t *testing.T) {
|
||||
r := recipe.Get("abra-test-recipe")
|
||||
err := r.EnsureExists()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct{ appEnv map[string]string }{
|
||||
{map[string]string{"COMPOSE_FILE": "compose.yml::compose.foo.yml"}},
|
||||
{map[string]string{"COMPOSE_FILE": "doesnt.exist.yml"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
_, err := r.GetComposeFiles(test.appEnv)
|
||||
if err == nil {
|
||||
t.Fatalf("should have failed: %v", test.appEnv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilters(t *testing.T) {
|
||||
oldDir := config.RECIPES_DIR
|
||||
config.RECIPES_DIR = "./testdata"
|
||||
defer func() {
|
||||
config.RECIPES_DIR = oldDir
|
||||
}()
|
||||
|
||||
app, err := appPkg.NewApp(envfile.AppEnv{
|
||||
"DOMAIN": "test.example.com",
|
||||
"RECIPE": "test-recipe",
|
||||
}, "test_example_com", appPkg.AppFile{
|
||||
Path: "./testdata/filtertest.end",
|
||||
Server: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
compareFilter(t, f, map[string]map[string]bool{
|
||||
"name": {
|
||||
"test_example_com": true,
|
||||
},
|
||||
})
|
||||
|
||||
f2, err := app.Filters(false, true)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
compareFilter(t, f2, map[string]map[string]bool{
|
||||
"name": {
|
||||
"^test_example_com": true,
|
||||
},
|
||||
})
|
||||
|
||||
f3, err := app.Filters(true, false)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
compareFilter(t, f3, map[string]map[string]bool{
|
||||
"name": {
|
||||
"test_example_com_bar": true,
|
||||
"test_example_com_foo": true,
|
||||
},
|
||||
})
|
||||
|
||||
f4, err := app.Filters(true, true)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
compareFilter(t, f4, map[string]map[string]bool{
|
||||
"name": {
|
||||
"^test_example_com_bar": true,
|
||||
"^test_example_com_foo": true,
|
||||
},
|
||||
})
|
||||
|
||||
f5, err := app.Filters(false, false, "foo")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
compareFilter(t, f5, map[string]map[string]bool{
|
||||
"name": {
|
||||
"test_example_com_foo": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func compareFilter(t *testing.T, f1 filters.Args, f2 map[string]map[string]bool) {
|
||||
t.Helper()
|
||||
j1, err := f1.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
j2, err := json.Marshal(f2)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if diff := cmp.Diff(string(j2), string(j1)); diff != "" {
|
||||
t.Errorf("filters mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRecipeVersionOverwrite(t *testing.T) {
|
||||
app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer t.Cleanup(func() {
|
||||
if err := app.WipeRecipeVersion(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
assert.Equal(t, "", app.Recipe.EnvVersion)
|
||||
|
||||
if err := app.WriteRecipeVersion("foo", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "foo", app.Recipe.EnvVersion)
|
||||
}
|
||||
98
pkg/app/compose.go
Normal file
98
pkg/app/compose.go
Normal file
@ -0,0 +1,98 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/pkg/envfile"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
)
|
||||
|
||||
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
|
||||
// to signal which recipe is connected to the deployed app
|
||||
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debugf("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName)
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
|
||||
service.Deploy.Labels[labelKey] = recipe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
|
||||
// to signal if the app is deployed in chaos mode
|
||||
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debugf("set label 'coop-cloud.%s.chaos' to %v for %s", stackName, chaos, stackName)
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", stackName)
|
||||
service.Deploy.Labels[labelKey] = strconv.FormatBool(chaos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetChaosVersionLabel adds the label 'coop-cloud.${STACK_NAME}.chaos-version=$(GIT_COMMIT)' to the app container
|
||||
func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosVersion string) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debugf("set label 'coop-cloud.%s.chaos-version' to %v for %s", stackName, chaosVersion, stackName)
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.chaos-version", stackName)
|
||||
service.Deploy.Labels[labelKey] = chaosVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SetVersionLabel(compose *composetypes.Config, stackName string, version string) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debugf("set label 'coop-cloud.%s.version' to %v for %s", stackName, version, stackName)
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName)
|
||||
service.Deploy.Labels[labelKey] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetUpdateLabel adds env ENABLE_AUTO_UPDATE as label to enable/disable the
|
||||
// auto update process for this app. The default if this variable is not set is to disable
|
||||
// the auto update process.
|
||||
func SetUpdateLabel(compose *composetypes.Config, stackName string, appEnv envfile.AppEnv) {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
enable_auto_update, exists := appEnv["ENABLE_AUTO_UPDATE"]
|
||||
if !exists {
|
||||
enable_auto_update = "false"
|
||||
}
|
||||
log.Debugf("set label 'coop-cloud.%s.autoupdate' to %s for %s", stackName, enable_auto_update, stackName)
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.autoupdate", stackName)
|
||||
service.Deploy.Labels[labelKey] = enable_auto_update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLabel reads docker labels in the format of "coop-cloud.${STACK_NAME}.${LABEL}" from the local compose files
|
||||
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
|
||||
log.Debugf("get label '%s'", labelKey)
|
||||
if labelValue, ok := service.Deploy.Labels[labelKey]; ok {
|
||||
return labelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Debugf("no %s label found for %s", label, stackName)
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value
|
||||
func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) {
|
||||
timeout := 50 // Default Timeout
|
||||
var err error = nil
|
||||
if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" {
|
||||
log.Debugf("timeout label: %s", timeoutLabel)
|
||||
timeout, err = strconv.Atoi(timeoutLabel)
|
||||
}
|
||||
return timeout, err
|
||||
}
|
||||
2
pkg/app/testdata/filtertest.env
vendored
Normal file
2
pkg/app/testdata/filtertest.env
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
RECIPE=test-recipe
|
||||
DOMAIN=test.example.com
|
||||
6
pkg/app/testdata/test-recipe/compose.yml
vendored
Normal file
6
pkg/app/testdata/test-recipe/compose.yml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
foo:
|
||||
image: debian
|
||||
bar:
|
||||
image: debian
|
||||
@ -2,41 +2,123 @@ package autocomplete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
appPkg "coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AppNameComplete copletes app names
|
||||
func AppNameComplete(c *cli.Context) {
|
||||
appNames, err := config.GetAppNames()
|
||||
// AppNameComplete copletes app names.
|
||||
func AppNameComplete() ([]string, cobra.ShellCompDirective) {
|
||||
appFiles, err := app.LoadAppFiles("")
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
var appNames []string
|
||||
for appName := range appFiles {
|
||||
appNames = append(appNames, appName)
|
||||
}
|
||||
|
||||
for _, a := range appNames {
|
||||
fmt.Println(a)
|
||||
}
|
||||
return appNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// RecipeNameComplete completes recipe names
|
||||
func RecipeNameComplete(c *cli.Context) {
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
func ServiceNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
|
||||
serviceNames, err := app.GetAppServiceNames(appName)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
return serviceNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// RecipeNameComplete completes recipe names.
|
||||
func RecipeNameComplete() ([]string, cobra.ShellCompDirective) {
|
||||
catl, err := recipe.ReadRecipeCatalogue(false)
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
var recipeNames []string
|
||||
for name := range catl {
|
||||
fmt.Println(name)
|
||||
recipeNames = append(recipeNames, name)
|
||||
}
|
||||
|
||||
return recipeNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// RecipeVersionComplete completes versions for the recipe.
|
||||
func RecipeVersionComplete(recipeName string) ([]string, cobra.ShellCompDirective) {
|
||||
catl, err := recipe.ReadRecipeCatalogue(true)
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
var recipeVersions []string
|
||||
for _, v := range catl[recipeName].Versions {
|
||||
for v2 := range v {
|
||||
recipeVersions = append(recipeVersions, v2)
|
||||
}
|
||||
}
|
||||
|
||||
return recipeVersions, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// ServerNameComplete completes server names.
|
||||
func ServerNameComplete() ([]string, cobra.ShellCompDirective) {
|
||||
files, err := app.LoadAppFiles("")
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
var serverNames []string
|
||||
for _, appFile := range files {
|
||||
serverNames = append(serverNames, appFile.Server)
|
||||
}
|
||||
|
||||
return serverNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// CommandNameComplete completes recipe commands.
|
||||
func CommandNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
|
||||
app, err := app.Get(appName)
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
cmdNames, err := appPkg.ReadAbraShCmdNames(app.Recipe.AbraShPath)
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
sort.Strings(cmdNames)
|
||||
|
||||
return cmdNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
// SecretsComplete completes recipe secrets.
|
||||
func SecretComplete(recipeName string) ([]string, cobra.ShellCompDirective) {
|
||||
r := recipe.Get(recipeName)
|
||||
|
||||
config, err := r.GetComposeConfig(nil)
|
||||
if err != nil {
|
||||
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||
return []string{err}, cobra.ShellCompDirectiveError
|
||||
}
|
||||
|
||||
var secretNames []string
|
||||
for name := range config.Secrets {
|
||||
secretNames = append(secretNames, name)
|
||||
}
|
||||
|
||||
return secretNames, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
||||
87
pkg/catalogue/catalogue.go
Normal file
87
pkg/catalogue/catalogue.go
Normal file
@ -0,0 +1,87 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
// EnsureCatalogue ensures that the catalogue is cloned locally & present.
|
||||
func EnsureCatalogue() error {
|
||||
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
|
||||
if _, err := os.Stat(catalogueDir); err != nil && os.IsNotExist(err) {
|
||||
log.Debugf("catalogue is missing, retrieving now")
|
||||
|
||||
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
|
||||
if err := gitPkg.Clone(catalogueDir, url); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureIsClean makes sure that the catalogue has no unstaged changes.
|
||||
func EnsureIsClean() error {
|
||||
isClean, err := gitPkg.IsClean(config.CATALOGUE_DIR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !isClean {
|
||||
msg := "%s has locally unstaged changes? please commit/remove your changes before proceeding"
|
||||
return fmt.Errorf(msg, config.CATALOGUE_DIR)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureUpToDate ensures that the local catalogue is up to date.
|
||||
func EnsureUpToDate() error {
|
||||
repo, err := git.PlainOpen(config.CATALOGUE_DIR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remotes, err := repo.Remotes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(remotes) == 0 {
|
||||
msg := "cannot ensure %s is up-to-date, no git remotes configured"
|
||||
log.Debugf(msg, config.CATALOGUE_DIR)
|
||||
return nil
|
||||
}
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branch, err := gitPkg.CheckoutDefaultBranch(repo, config.CATALOGUE_DIR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := &git.PullOptions{
|
||||
Force: true,
|
||||
ReferenceName: branch,
|
||||
}
|
||||
|
||||
if err := worktree.Pull(opts); err != nil {
|
||||
if !strings.Contains(err.Error(), "already up-to-date") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -2,24 +2,46 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
sshPkg "coopcloud.tech/abra/pkg/ssh"
|
||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// New initiates a new Docker client.
|
||||
func New(contextName string) (*client.Client, error) {
|
||||
// Conf is a Docker client configuration.
|
||||
type Conf struct {
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// Opt is a Docker client option.
|
||||
type Opt func(c *Conf)
|
||||
|
||||
// WithTimeout specifies a timeout for a Docker client.
|
||||
func WithTimeout(timeout int) Opt {
|
||||
return func(c *Conf) {
|
||||
c.Timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// New initiates a new Docker client. New client connections are validated so
|
||||
// that we ensure connections via SSH to the daemon can succeed. It takes into
|
||||
// account that you may only want the local client and not communicate via SSH.
|
||||
// For this use-case, please pass "default" as the contextName.
|
||||
func New(serverName string, opts ...Opt) (*client.Client, error) {
|
||||
var clientOpts []client.Opt
|
||||
|
||||
if contextName != "default" {
|
||||
context, err := GetContext(contextName)
|
||||
if serverName != "default" {
|
||||
context, err := GetContext(serverName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("unknown server, run \"abra server add %s\"?", serverName)
|
||||
}
|
||||
|
||||
ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
|
||||
@ -27,9 +49,17 @@ func New(contextName string) (*client.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
helper := commandconnPkg.NewConnectionHelper(ctxEndpoint)
|
||||
conf := &Conf{}
|
||||
for _, opt := range opts {
|
||||
opt(conf)
|
||||
}
|
||||
|
||||
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint, conf.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
// No tls, no proxy
|
||||
Transport: &http.Transport{
|
||||
DialContext: helper.Dialer,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
@ -55,7 +85,20 @@ func New(contextName string) (*client.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.Debugf("created client for %s", contextName)
|
||||
log.Debugf("created client for %s", serverName)
|
||||
|
||||
info, err := cl.Info(context.Background())
|
||||
if err != nil {
|
||||
return cl, sshPkg.Fatal(serverName, err)
|
||||
}
|
||||
|
||||
if info.Swarm.LocalNodeState == "inactive" {
|
||||
if serverName != "default" {
|
||||
return cl, fmt.Errorf("swarm mode not enabled on %s?", serverName)
|
||||
}
|
||||
|
||||
return cl, errors.New("swarm mode not enabled on local server?")
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
38
pkg/client/configs.go
Normal file
38
pkg/client/configs.go
Normal file
@ -0,0 +1,38 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func GetConfigs(cl *client.Client, ctx context.Context, server string, fs filters.Args) ([]swarm.Config, error) {
|
||||
configList, err := cl.ConfigList(ctx, swarm.ConfigListOptions{Filters: fs})
|
||||
if err != nil {
|
||||
return configList, err
|
||||
}
|
||||
|
||||
return configList, nil
|
||||
}
|
||||
|
||||
func GetConfigNames(configs []swarm.Config) []string {
|
||||
var confNames []string
|
||||
|
||||
for _, conf := range configs {
|
||||
confNames = append(confNames, conf.Spec.Name)
|
||||
}
|
||||
|
||||
return confNames
|
||||
}
|
||||
|
||||
func RemoveConfigs(cl *client.Client, ctx context.Context, configNames []string, force bool) error {
|
||||
for _, confName := range configNames {
|
||||
if err := cl.ConfigRemove(context.Background(), confName); err != nil {
|
||||
return fmt.Errorf("conf %s: %s", confName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -5,28 +5,25 @@ import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||
dConfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/context/docker"
|
||||
contextStore "github.com/docker/cli/cli/context/store"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Context = contextStore.Metadata
|
||||
|
||||
func CreateContext(contextName string, user string, port string) error {
|
||||
host := contextName
|
||||
if user != "" {
|
||||
host = fmt.Sprintf("%s@%s", user, host)
|
||||
}
|
||||
if port != "" {
|
||||
host = fmt.Sprintf("%s:%s", host, port)
|
||||
}
|
||||
host = fmt.Sprintf("ssh://%s", host)
|
||||
// CreateContext creates a new Docker context.
|
||||
func CreateContext(contextName string) error {
|
||||
host := fmt.Sprintf("ssh://%s", contextName)
|
||||
|
||||
if err := createContext(contextName, host); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("created the %s context", contextName)
|
||||
|
||||
log.Debugf("created the %s context", contextName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,193 +1,28 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/web"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/containers/image/docker"
|
||||
"github.com/containers/image/types"
|
||||
"github.com/distribution/reference"
|
||||
)
|
||||
|
||||
type RawTag struct {
|
||||
Layer string
|
||||
Name string
|
||||
}
|
||||
// GetRegistryTags retrieves all tags of an image from a container registry.
|
||||
func GetRegistryTags(img reference.Named) ([]string, error) {
|
||||
var tags []string
|
||||
|
||||
type RawTags []RawTag
|
||||
ref, err := docker.ParseReference(fmt.Sprintf("//%s", img))
|
||||
if err != nil {
|
||||
return tags, fmt.Errorf("failed to parse image %s, saw: %s", img, err.Error())
|
||||
}
|
||||
|
||||
var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags"
|
||||
|
||||
func GetRegistryTags(image string) (RawTags, error) {
|
||||
var tags RawTags
|
||||
|
||||
tagsUrl := fmt.Sprintf(registryURL, image)
|
||||
if err := web.ReadJSON(tagsUrl, &tags); err != nil {
|
||||
ctx := context.Background()
|
||||
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
|
||||
if err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func basicAuth(username, password string) string {
|
||||
auth := username + ":" + password
|
||||
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
}
|
||||
|
||||
// getRegv2Token retrieves a registry v2 authentication token.
|
||||
func getRegv2Token(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (string, error) {
|
||||
img := reference.Path(image)
|
||||
tokenURL := "https://auth.docker.io/token"
|
||||
values := fmt.Sprintf("service=registry.docker.io&scope=repository:%s:pull", img)
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", tokenURL, values)
|
||||
req, err := retryablehttp.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if registryUsername != "" && registryPassword != "" {
|
||||
logrus.Debugf("using registry log in credentials for token request")
|
||||
auth := basicAuth(registryUsername, registryPassword)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth))
|
||||
}
|
||||
|
||||
client := web.NewHTTPRetryClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
_, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
tokenRes := struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Expiry int `json:"expires_in"`
|
||||
Issued string `json:"issued_at"`
|
||||
Token string `json:"token"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(body, &tokenRes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenRes.Token, nil
|
||||
}
|
||||
|
||||
// GetTagDigest retrieves an image digest from a v2 registry
|
||||
func GetTagDigest(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (string, error) {
|
||||
img := reference.Path(image)
|
||||
tag := image.(reference.NamedTagged).Tag()
|
||||
manifestURL := fmt.Sprintf("https://index.docker.io/v2/%s/manifests/%s", img, tag)
|
||||
|
||||
req, err := retryablehttp.NewRequest("GET", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := getRegv2Token(cl, image, registryUsername, registryPassword)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("unable to retrieve registry token?")
|
||||
}
|
||||
|
||||
req.Header = http.Header{
|
||||
"Accept": []string{
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
},
|
||||
"Authorization": []string{fmt.Sprintf("Bearer %s", token)},
|
||||
}
|
||||
|
||||
client := web.NewHTTPRetryClient()
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
_, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registryResT1 := struct {
|
||||
SchemaVersion int
|
||||
MediaType string
|
||||
Manifests []struct {
|
||||
MediaType string
|
||||
Size int
|
||||
Digest string
|
||||
Platform struct {
|
||||
Architecture string
|
||||
Os string
|
||||
}
|
||||
}
|
||||
}{}
|
||||
|
||||
registryResT2 := struct {
|
||||
SchemaVersion int
|
||||
MediaType string
|
||||
Config struct {
|
||||
MediaType string
|
||||
Size int
|
||||
Digest string
|
||||
}
|
||||
Layers []struct {
|
||||
MediaType string
|
||||
Size int
|
||||
Digest string
|
||||
}
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(body, ®istryResT1); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var digest string
|
||||
for _, manifest := range registryResT1.Manifests {
|
||||
if string(manifest.Platform.Architecture) == "amd64" {
|
||||
digest = strings.Split(manifest.Digest, ":")[1][:7]
|
||||
}
|
||||
}
|
||||
|
||||
if digest == "" {
|
||||
if err := json.Unmarshal(body, ®istryResT2); err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest = strings.Split(registryResT2.Config.Digest, ":")[1][:7]
|
||||
}
|
||||
|
||||
if digest == "" {
|
||||
return "", fmt.Errorf("Unable to retrieve amd64 digest for %s", image)
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
@ -4,20 +4,14 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func StoreSecret(secretName, secretValue, server string) error {
|
||||
cl, err := New(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
func StoreSecret(cl *client.Client, secretName, secretValue string) error {
|
||||
ann := swarm.Annotations{Name: secretName}
|
||||
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
|
||||
|
||||
// We don't bother with the secret IDs for now
|
||||
if _, err := cl.SecretCreate(ctx, spec); err != nil {
|
||||
if _, err := cl.SecretCreate(context.Background(), spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -2,50 +2,61 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Volume, error) {
|
||||
|
||||
cl, err := New(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs := filters.NewArgs()
|
||||
fs.Add("name", appName)
|
||||
|
||||
volumeListOKBody, err := cl.VolumeList(ctx, fs)
|
||||
func GetVolumes(cl *client.Client, ctx context.Context, server string, fs filters.Args) ([]*volume.Volume, error) {
|
||||
volumeListOKBody, err := cl.VolumeList(ctx, volume.ListOptions{Filters: fs})
|
||||
volumeList := volumeListOKBody.Volumes
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return volumeList, err
|
||||
}
|
||||
|
||||
return volumeList, nil
|
||||
}
|
||||
|
||||
func GetVolumeNames(volumes []*types.Volume) []string {
|
||||
func GetVolumeNames(volumes []*volume.Volume) []string {
|
||||
var volumeNames []string
|
||||
|
||||
for _, vol := range volumes {
|
||||
volumeNames = append(volumeNames, vol.Name)
|
||||
}
|
||||
|
||||
return volumeNames
|
||||
}
|
||||
|
||||
func RemoveVolumes(ctx context.Context, server string, volumeNames []string, force bool) error {
|
||||
cl, err := New(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func RemoveVolumes(cl *client.Client, ctx context.Context, volumeNames []string, force bool, retries int) error {
|
||||
for _, volName := range volumeNames {
|
||||
err := cl.VolumeRemove(ctx, volName, force)
|
||||
err := retryFunc(5, func() error {
|
||||
return cl.VolumeRemove(context.Background(), volName, force)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("volume %s: %s", volName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// retryFunc retries the given function for the given retries. After the nth
|
||||
// retry it waits (n + 1)^2 seconds before the next retry (starting with n=0).
|
||||
// It returns an error if the function still failed after the last retry.
|
||||
func retryFunc(retries int, fn func() error) error {
|
||||
for i := 0; i < retries; i++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if i+1 < retries {
|
||||
sleep := time.Duration(i+1) * time.Duration(i+1)
|
||||
log.Infof("%s: waiting %d seconds before next retry", err, sleep)
|
||||
time.Sleep(sleep * time.Second)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%d retries failed", retries)
|
||||
}
|
||||
|
||||
26
pkg/client/volumes_test.go
Normal file
26
pkg/client/volumes_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRetryFunc(t *testing.T) {
|
||||
err := retryFunc(1, func() error { return nil })
|
||||
if err != nil {
|
||||
t.Errorf("should not return an error: %s", err)
|
||||
}
|
||||
|
||||
i := 0
|
||||
fn := func() error {
|
||||
i++
|
||||
return fmt.Errorf("oh no, something went wrong!")
|
||||
}
|
||||
err = retryFunc(2, fn)
|
||||
if err == nil {
|
||||
t.Error("should return an error")
|
||||
}
|
||||
if i != 2 {
|
||||
t.Errorf("The function should have been called 1 times, got %d", i)
|
||||
}
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
loader "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UpdateTag updates an image tag in-place on file system local compose files.
|
||||
func UpdateTag(pattern, image, tag, recipeName string) error {
|
||||
composeFiles, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", "))
|
||||
|
||||
for _, composeFile := range composeFiles {
|
||||
opts := stack.Deploy{Composefiles: []string{composeFile}}
|
||||
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
|
||||
sampleEnv, err := config.ReadEnv(envSamplePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compose, err := loader.LoadComposefile(opts, sampleEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, service := range compose.Services {
|
||||
if service.Image == "" {
|
||||
continue // may be a compose.$optional.yml file
|
||||
}
|
||||
|
||||
img, _ := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
composeImage := reference.Path(img)
|
||||
if strings.Contains(composeImage, "library") {
|
||||
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
|
||||
// postgres:<tag>, i.e. images which do not have a username in the
|
||||
// first position of the string
|
||||
composeImage = strings.Split(composeImage, "/")[1]
|
||||
}
|
||||
composeTag := img.(reference.NamedTagged).Tag()
|
||||
|
||||
logrus.Debugf("parsed %s from %s", composeTag, service.Image)
|
||||
|
||||
if image == composeImage {
|
||||
bytes, err := ioutil.ReadFile(composeFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
|
||||
new := fmt.Sprintf("%s:%s", composeImage, tag)
|
||||
replacedBytes := strings.Replace(string(bytes), old, new, -1)
|
||||
|
||||
logrus.Debugf("updating %s to %s in %s", old, new, compose.Filename)
|
||||
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates a label in-place on file system local compose files.
|
||||
func UpdateLabel(pattern, serviceName, label, recipeName string) error {
|
||||
composeFiles, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("considering %s config(s) for label update", strings.Join(composeFiles, ", "))
|
||||
|
||||
for _, composeFile := range composeFiles {
|
||||
opts := stack.Deploy{Composefiles: []string{composeFile}}
|
||||
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
|
||||
sampleEnv, err := config.ReadEnv(envSamplePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compose, err := loader.LoadComposefile(opts, sampleEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceExists := false
|
||||
var service composetypes.ServiceConfig
|
||||
for _, s := range compose.Services {
|
||||
if s.Name == serviceName {
|
||||
service = s
|
||||
serviceExists = true
|
||||
}
|
||||
}
|
||||
|
||||
if !serviceExists {
|
||||
continue
|
||||
}
|
||||
|
||||
discovered := false
|
||||
for oldLabel, value := range service.Deploy.Labels {
|
||||
if strings.HasPrefix(oldLabel, "coop-cloud") {
|
||||
discovered = true
|
||||
|
||||
bytes, err := ioutil.ReadFile(composeFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
|
||||
replacedBytes := strings.Replace(string(bytes), old, label, -1)
|
||||
|
||||
if old == label {
|
||||
logrus.Warnf("%s is already set, nothing to do?", label)
|
||||
return nil
|
||||
}
|
||||
|
||||
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
|
||||
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Infof("synced label %s to service %s", label, serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
if !discovered {
|
||||
logrus.Warn("no existing label found, automagic insertion not supported yet")
|
||||
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user