forked from toolshed/abra
Compare commits
1382 Commits
0.3.1-rc1
...
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
|
|||
|
636dc82258
|
|||
|
66d5453248
|
|||
|
ba9abcb0d7
|
|||
|
a1cbf21f61
|
|||
|
bd1da39374
|
|||
|
8b90519bc9
|
|||
|
65feda7f1d
|
|||
|
64e223a810
|
|||
|
379e01d855
|
|||
|
a421c0dca5
|
|||
|
abf56f9054
|
|||
|
4dec3c4646
|
|||
|
c900cebc30
|
|||
|
30209de3e2
|
|||
|
625747d048
|
|||
|
a71b070921
|
|||
|
33ff04c686
|
|||
|
c69a3c23c5
|
|||
|
0b46909961
|
|||
|
832e8e5a96
|
|||
|
abf83aa641
|
|||
|
1df69aa259
|
|||
|
7596a67ad5
|
|||
|
93c7612efc
|
|||
|
2c78ac22e0
|
|||
|
13661c72ce
|
|||
|
454092644a
|
|||
|
224c0c38db
|
|||
|
560e0eab86
|
|||
|
b92fdbbd52
|
|||
|
0a550363b8
|
|||
|
3119220c21
|
|||
|
49f565e5db
|
|||
|
94522178b1
|
|||
|
810bc27967
|
|||
|
35d95fb9fb
|
|||
|
d26fabe8ef
|
|||
|
84bf3ffa50
|
|||
|
575485ec7a
|
|||
|
0b17292219
|
|||
|
fffd8b2647
|
|||
|
c07128b308
|
|||
|
929ff88013
|
|||
|
0353427c71
|
|||
|
7a0d18ceb6
|
|||
|
8992050409
|
|||
|
abd094387f
|
|||
|
a556ca625b
|
|||
|
1b7836009f
|
|||
|
eb3509ab3f
|
|||
|
87851d26f7
|
|||
|
c4f344b50a
|
|||
|
60e4dfd9cb
|
|||
|
d957adb675
|
|||
|
5254af0fe4
|
|||
|
ce96269be0
|
|||
|
299276c383
|
|||
|
866cdd1f29
|
|||
|
95d385c420
|
|||
|
605e2553b8
|
|||
|
1245827dff
|
|||
|
9bdb07463c
|
|||
|
be26f80f03
|
|||
|
930ff68bb2
|
|||
|
62441acf03
|
|||
|
7460668ef4
|
|||
|
047d0e6fbc
|
|||
|
8785f66391
|
|||
|
24882e95b4
|
|||
|
1fd0941239
|
|||
|
26a11533b4
|
|||
|
b4f48c3c59
|
|||
|
43e68a99b0
|
|||
|
bac6fb0fa8
|
|||
|
dc9c9715ce
|
|||
|
1f91b3bb03
|
|||
|
a700aca23d
|
|||
|
5cacd09a04
|
|||
|
6a98024a2b
|
|||
|
e85117be22
|
|||
|
fb24357d38
|
|||
|
f5d2d3adf6
|
|||
|
07119b0575
|
|||
|
d2a6e35986
|
|||
|
0aa37fcee8
|
|||
|
eb1b6be4c5
|
|||
|
b98397144a
|
|||
|
4c186678b8
|
|||
|
b1d9d9d858
|
|||
|
a06043375d
|
|||
|
3eef1e8587
|
|||
|
37e48f262b
|
|||
|
06cc5d1cc3
|
|||
|
c13f438580
|
|||
|
5cd4317580
|
|||
|
2ba1ec3df0
|
|||
|
34cdb9c9d8
|
|||
|
9c281d8608
|
|||
|
321ba1e0ec
|
|||
|
c5a74e9f6b
|
|||
|
f8191ac248
|
|||
|
027c8a1420
|
|||
|
cdc08ae95a
|
|||
|
3f35510507
|
|||
|
9f70a69bbf
|
|||
|
b0834925a3
|
|||
|
86d87253c5
|
|||
|
17340a79da
|
|||
|
779c810521
|
|||
|
9cc2554846
|
|||
|
9a1cf258a5
|
|||
|
ba8138079f
|
|||
|
8735a8f0ea
|
|||
|
a84a5bc320
|
|||
|
ae0e7b8e4c
|
|||
|
c0caf14d74
|
|||
|
d66c558b5c
|
|||
|
c8541e1b9d
|
|||
|
653b6c6d49
|
|||
|
e2c3bc35c3
|
|||
|
6937bfbb0d
|
|||
|
decfe095fe
|
|||
|
4283f130a2
|
|||
|
3b5354b2a5
|
|||
|
14400d4ed8
|
|||
|
dddf84d92b
|
|||
|
fefb042716
|
|||
|
ab8db8df64
|
|||
|
20f7a18caa
|
|||
|
58a24a50e1
|
|||
|
e839f100df
|
|||
|
41a757b7ed
|
|||
|
4b4298caf1
|
|||
|
8e8c241fdf
|
|||
|
9b8ff1ddcd
|
|||
|
a85cfe40d0
|
|||
|
fc29ca6fce
|
|||
|
cfb02f45ed
|
|||
|
696172ad48
|
|||
|
4089949a3f
|
|||
|
a75b01e78a
|
|||
|
014d32112e
|
|||
|
a7894cbda9
|
|||
|
e03761f251
|
|||
|
190c1033e6
|
|||
|
15d1e9dee0
|
|||
|
0362928840
|
|||
|
844961d016
|
|||
|
d0cc51b829
|
|||
|
606b5ac3e4
|
|||
| 6f1bf258b3 | |||
|
7a5aa1b005
|
|||
|
db453f0ab1
|
|||
|
a07e71f7df
|
|||
|
4c6d52c426
|
|||
|
327c5adef2
|
|||
|
0dc8425a27
|
|||
|
48c965bb21
|
|||
|
5513754c22
|
|||
|
3a27d9d9fb
|
|||
|
04b58230ea
|
|||
|
1b9097f9f3
|
|||
|
3d100093dc
|
|||
|
ef4383209e
|
|||
|
74f688350b
|
|||
|
737a22aacc
|
|||
|
56a1e7f8c4
|
|||
|
6be2f36334
|
|||
|
a18d0e290d
|
|||
|
7e0feec311
|
|||
|
29a4d05944
|
|||
|
b72bad955a
|
|||
|
e9b4541c91
|
|||
|
5b1b16d64a
|
|||
|
ec7223146b
|
|||
|
fa45264ea0
|
|||
|
f57222d6aa
|
|||
|
28d10928a4
|
|||
|
0f4da38f98
|
|||
| 11c2d1efe6 | |||
|
2b1cc9f6dd
|
|||
|
6100a636a6
|
|||
|
ddbf923338
|
|||
|
c1a00520dc
|
|||
|
0dc4b2beef
|
|||
|
f75284364d
|
|||
|
fbc3b48d39
|
|||
|
6f0d8b190d
|
|||
|
fc3742212c
|
|||
|
fccbd7c7d7
|
|||
|
2457b5fe95
|
|||
|
72df640d99
|
|||
|
ae9e66c319
|
|||
|
3589a7d56e
|
|||
|
8d499c0810
|
|||
|
cb2bb3f532
|
|||
|
0a903f041f
|
|||
|
053a06ccba
|
|||
|
398deec272
|
|||
|
bf82bc9c7f
|
|||
|
217d4bc2cc
|
|||
|
9c8e6b63a6
|
|||
|
5113db1612
|
|||
|
66666e30b7
|
|||
|
88d4984248
|
|||
|
bc34be4357
|
|||
|
3d1aa55587
|
|||
|
e7469acf5b
|
|||
|
a293179e89
|
|||
|
b912e73c5e
|
|||
|
4c66e44b3a
|
|||
|
033bad3d10
|
|||
|
a750344653
|
|||
|
f5caf5587a
|
|||
|
fdc9e8b5fd
|
|||
|
75edcabb23
|
|||
|
fa0a63c11d
|
|||
|
3d3eefb2fe
|
|||
|
6998a87eef
|
|||
|
b71a379788
|
|||
|
ba217dccbd
|
|||
|
45259b3266
|
|||
|
59b80d5def
|
|||
|
8f6e1de1a1
|
|||
|
cd0d3b8892
|
|||
|
0d1f65daac
|
|||
|
cf1b46fa61
|
|||
|
0fe0ffbafa
|
|||
|
af3def7267
|
|||
|
c7de9c0719
|
|||
|
cf5ee4e682
|
|||
|
9ddf69b988
|
|||
|
a925da8dee
|
|||
|
06f8078866
|
|||
|
467947edf2
|
|||
|
512cd9d85b
|
|||
|
b8e2d1de67
|
|||
|
3b7a8e6498
|
|||
|
5bae262a79
|
|||
|
6ad253b866
|
|||
| b603069514 | |||
| d999cedd97 | |||
|
8215bb455b
|
|||
|
37ab9a9c08
|
|||
|
48dd9cdeed
|
|||
|
d02e1f247f
|
|||
|
d087a60e09
|
|||
|
48e16c414c
|
|||
|
f3e55e5023
|
|||
|
ae6adace50
|
|||
|
32dcddb631
|
|||
|
3dbd343600
|
|||
|
8393f4b134
|
|||
|
8e56607cc9
|
|||
|
85a543afac
|
|||
|
665396b679
|
|||
|
870c561fee
|
|||
|
3fb43ffa2c
|
|||
|
2bc2f8630b
|
|||
|
6094dfaf92
|
|||
|
3789e56404
|
|||
|
2db5378418
|
|||
|
7d8f3f1fab
|
|||
|
9be78bc5fa
|
|||
|
6c87d501e6
|
|||
| 930c29f4a2 | |||
| 1d6c3e98e4 | |||
| a90f3b7463 | |||
| 962f566228 | |||
| 9896c57399 | |||
| 748d607ddc | |||
| 3901258a96 | |||
| 4347083f98 | |||
| 4641a942d8 | |||
| 759a00eeb3 | |||
| d1526fad21 | |||
|
6ef15e0a26
|
|||
| dd0f328a65 | |||
| aea5cc69c3 | |||
| b02475eca5 | |||
| d0a30f6b7b | |||
| 8635922b9f | |||
| 9d62fff074 | |||
| 711c4e5ee8 | |||
| cb32e88cde | |||
| a18729bf98 | |||
| dbf84b7640 | |||
| 75db249053 | |||
| fdf4fc6737 | |||
| ef6a9abba9 | |||
| ce57d5ed54 | |||
| 3b01b1bb2e | |||
| fbdb792795 | |||
| 900f40f07a | |||
| ecd2a63f0a | |||
| 304b70639f | |||
| d821975aa2 | |||
| 1b836dbab6 | |||
| fc51cf7775 | |||
| a7ebcd8950 | |||
| e589709cb0 | |||
| 56c3e070f5 | |||
| cc37615d83 | |||
| 0b37f63248 | |||
| 9c3a06a7d9 | |||
| cdef8b5ea5 | |||
| cba261b18c | |||
| 1f6e4fa4a3 | |||
| 4a245c3e02 | |||
|
299faa1adf
|
|||
| 704e773a16 | |||
| 7143d09fd4 | |||
| 4e76d49c80 | |||
| c9dff0c3bd | |||
| e77e72a9e6 | |||
| af6f759c92 | |||
| 034295332c | |||
| dac2489e6d | |||
| 7bdc1946a2 | |||
| 2439643895 | |||
| 0876f677d1 | |||
| 31dafb3ae4 | |||
| 915083b426 | |||
| 486a1717e7 | |||
| 9122c0a9b8 | |||
| 85ff04202f | |||
| ecba4e01f1 | |||
| 751b187df6 | |||
| f74261dbe6 | |||
| 2600a8137c | |||
| b6a6163eff | |||
|
c25b2b17df
|
|||
| 713308e0b8 | |||
| fcbf41ee95 | |||
|
5add4ccc1b
|
|||
|
9220a8c09b
|
|||
| f78a04109c | |||
| b67ad02f87 | |||
| 215431696e | |||
| cd361237e7 | |||
| db10c7b849 | |||
| d38f82ebe7 | |||
| 59031595ea | |||
| 6f26b51f3e |
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
|
name: coopcloud.tech/abra
|
||||||
steps:
|
steps:
|
||||||
- name: make check
|
- name: make check
|
||||||
image: golang:1.17
|
image: golang:1.24
|
||||||
commands:
|
commands:
|
||||||
- make check
|
- 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
|
- name: make test
|
||||||
image: golang:1.17
|
image: golang:1.24
|
||||||
|
environment:
|
||||||
|
CATL_URL: https://git.coopcloud.tech/toolshed/recipes-catalogue-json.git
|
||||||
commands:
|
commands:
|
||||||
|
- mkdir -p $HOME/.abra
|
||||||
|
- git clone $CATL_URL $HOME/.abra/catalogue
|
||||||
- make test
|
- 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:
|
depends_on:
|
||||||
- make check
|
- make check
|
||||||
- make build
|
|
||||||
- make test
|
|
||||||
when:
|
|
||||||
status:
|
|
||||||
- failure
|
|
||||||
|
|
||||||
- name: fetch
|
- name: fetch
|
||||||
image: docker:git
|
image: docker:git
|
||||||
@ -49,13 +24,12 @@ steps:
|
|||||||
- git fetch --tags
|
- git fetch --tags
|
||||||
depends_on:
|
depends_on:
|
||||||
- make check
|
- make check
|
||||||
- make build
|
|
||||||
- make test
|
- make test
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
|
|
||||||
- name: release
|
- name: release
|
||||||
image: golang:1.17
|
image: goreleaser/goreleaser:v2.5.1
|
||||||
environment:
|
environment:
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
from_secret: goreleaser_gitea_token
|
from_secret: goreleaser_gitea_token
|
||||||
@ -63,12 +37,82 @@ steps:
|
|||||||
- name: deps
|
- name: deps
|
||||||
path: /go
|
path: /go
|
||||||
commands:
|
commands:
|
||||||
- curl -sL https://git.io/goreleaser | bash
|
- goreleaser release
|
||||||
depends_on:
|
depends_on:
|
||||||
- fetch
|
- fetch
|
||||||
when:
|
when:
|
||||||
event: tag
|
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:
|
volumes:
|
||||||
- name: deps
|
- name: deps
|
||||||
temp: {}
|
temp: {}
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
action:
|
||||||
|
exclude:
|
||||||
|
- synchronized
|
||||||
|
|||||||
@ -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/
|
# release automation
|
||||||
# export HCLOUD_TOKEN=$(pass show logins/hetzner/cicd/api_key)
|
# export GITEA_TOKEN=
|
||||||
# export CAPSUL_TOKEN=...
|
|
||||||
# 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)
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
abra
|
|
||||||
.vscode/
|
|
||||||
vendor/
|
|
||||||
.envrc
|
|
||||||
dist/
|
|
||||||
*fmtcoverage.html
|
*fmtcoverage.html
|
||||||
|
.e2e.env
|
||||||
|
.envrc
|
||||||
|
.vscode/
|
||||||
|
/abra
|
||||||
|
/kadabra
|
||||||
|
dist/
|
||||||
|
tests/integration/.bats
|
||||||
|
|||||||
@ -1,38 +1,76 @@
|
|||||||
---
|
---
|
||||||
project_name: abra
|
|
||||||
gitea_urls:
|
gitea_urls:
|
||||||
api: https://git.coopcloud.tech/api/v1
|
api: https://git.coopcloud.tech/api/v1
|
||||||
download: https://git.coopcloud.tech/
|
download: https://git.coopcloud.tech/
|
||||||
skip_tls_verify: false
|
skip_tls_verify: false
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./...
|
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- id: abra
|
||||||
- CGO_ENABLED=0
|
binary: abra
|
||||||
dir: cmd/abra
|
dir: cmd/abra
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- 386
|
||||||
|
- amd64
|
||||||
|
- arm
|
||||||
|
- arm64
|
||||||
|
goarm:
|
||||||
|
- 5
|
||||||
|
- 6
|
||||||
|
- 7
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-X 'main.Commit={{ .Commit }}'"
|
- "-X 'main.Commit={{ .Commit }}'"
|
||||||
- "-X 'main.Version={{ .Version }}'"
|
- "-X 'main.Version={{ .Version }}'"
|
||||||
archives:
|
- "-s"
|
||||||
- replacements:
|
- "-w"
|
||||||
386: i386
|
|
||||||
amd64: x86_64
|
- id: kadabra
|
||||||
format: binary
|
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:
|
checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ incpatch .Version }}-next"
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: desc
|
sort: desc
|
||||||
filters:
|
filters:
|
||||||
exclude:
|
exclude:
|
||||||
|
- "^Merge"
|
||||||
|
- "^Revert"
|
||||||
- "^WIP:"
|
- "^WIP:"
|
||||||
|
- "^chore(deps):"
|
||||||
- "^style:"
|
- "^style:"
|
||||||
- "^test:"
|
- "^test:"
|
||||||
- "^tests:"
|
- "^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/>.
|
||||||
64
Makefile
64
Makefile
@ -1,35 +1,55 @@
|
|||||||
ABRA := ./cmd/abra
|
ABRA := ./cmd/abra
|
||||||
COMMIT := $(shell git rev-list -1 HEAD)
|
KADABRA := ./cmd/kadabra
|
||||||
GOPATH := $(shell go env GOPATH)
|
COMMIT := $(shell git rev-list -1 HEAD)
|
||||||
LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
|
GOPATH := $(shell go env GOPATH)
|
||||||
|
GOVERSION := 1.24
|
||||||
|
LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
|
||||||
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
|
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
|
||||||
|
GCFLAGS := "all=-l -B"
|
||||||
|
|
||||||
export GOPRIVATE=coopcloud.tech
|
export GOPRIVATE=coopcloud.tech
|
||||||
|
|
||||||
all: run test install build clean format check static
|
# NOTE(d1): default `make` optimised for Abra hacking
|
||||||
|
all: format check build-abra test
|
||||||
|
|
||||||
run:
|
run-abra:
|
||||||
@go run -ldflags=$(LDFLAGS) $(ABRA)
|
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
|
||||||
|
|
||||||
install:
|
run-kadabra:
|
||||||
@go install -ldflags=$(LDFLAGS) $(ABRA)
|
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
|
||||||
|
|
||||||
build-dev:
|
install-abra:
|
||||||
@go build -ldflags=$(LDFLAGS) $(ABRA)
|
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
|
||||||
|
|
||||||
build:
|
install-kadabra:
|
||||||
@go build -ldflags=$(DIST_LDFLAGS) $(ABRA)
|
@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:
|
clean:
|
||||||
@rm '$(GOPATH)/bin/abra'
|
@rm '$(GOPATH)/bin/abra'
|
||||||
|
@rm '$(GOPATH)/bin/kadabra'
|
||||||
|
|
||||||
format:
|
format:
|
||||||
@gofmt -s -w .
|
@gofmt -s -w $$(find . -type f -name '*.go' | grep -v "/vendor/")
|
||||||
|
|
||||||
check:
|
check:
|
||||||
@test -z $$(gofmt -l .) || (echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
@test -z $$(gofmt -l $$(find . -type f -name '*.go' | grep -v "/vendor/")) || \
|
||||||
|
(echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
||||||
static:
|
|
||||||
@staticcheck $(ABRA)
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@go test ./... -cover -v
|
@go test ./... -cover -v
|
||||||
@ -37,9 +57,5 @@ test:
|
|||||||
loc:
|
loc:
|
||||||
@find . -name "*.go" | xargs wc -l
|
@find . -name "*.go" | xargs wc -l
|
||||||
|
|
||||||
loc-author:
|
deps:
|
||||||
@git ls-files -z | \
|
@go get -t -u ./...
|
||||||
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
|
|
||||||
|
|||||||
62
README.md
62
README.md
@ -1,61 +1,13 @@
|
|||||||
# abra
|
# `abra`
|
||||||
|
|
||||||
> https://coopcloud.tech
|
[](https://build.coopcloud.tech/toolshed/abra)
|
||||||
|
[](https://goreportcard.com/report/git.coopcloud.tech/toolshed/abra)
|
||||||
[](https://build.coopcloud.tech/coop-cloud/abra)
|
[](https://pkg.go.dev/coopcloud.tech/abra)
|
||||||
[](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/abra)
|
|
||||||
|
|
||||||
The Co-op Cloud utility belt 🎩🐇
|
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>
|
||||||
|
|
||||||
## Hacking
|
`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 💖
|
||||||
|
|
||||||
### Getting started
|
Please see [docs.coopcloud.tech/abra](https://docs.coopcloud.tech/abra) for help on install, upgrade, hacking, troubleshooting & more!
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@ -1,38 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
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 = &cobra.Command{
|
||||||
var AppCommand = &cli.Command{
|
Use: "app [cmd] [args] [flags]",
|
||||||
Name: "app",
|
Aliases: []string{"a"},
|
||||||
Usage: "Manage deployed apps",
|
Short: gotext.Get("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,
|
|
||||||
appDeployCommand,
|
|
||||||
appUpgradeCommand,
|
|
||||||
appUndeployCommand,
|
|
||||||
appBackupCommand,
|
|
||||||
appRestoreCommand,
|
|
||||||
appRemoveCommand,
|
|
||||||
appCheckCommand,
|
|
||||||
appListCommand,
|
|
||||||
appPsCommand,
|
|
||||||
appLogsCommand,
|
|
||||||
appCpCommand,
|
|
||||||
appRunCommand,
|
|
||||||
appRollbackCommand,
|
|
||||||
appSecretCommand,
|
|
||||||
appVolumeCommand,
|
|
||||||
appVersionCommand,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +1,307 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"github.com/urfave/cli/v2"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var backupAllServices bool
|
var AppBackupListCommand = &cobra.Command{
|
||||||
var backupAllServicesFlag = &cli.BoolFlag{
|
Use: "list <domain> [flags]",
|
||||||
Name: "all",
|
Aliases: []string{"ls"},
|
||||||
Value: false,
|
Short: "List the contents of a snapshot",
|
||||||
Destination: &backupAllServices,
|
Args: cobra.ExactArgs(1),
|
||||||
Aliases: []string{"a"},
|
ValidArgsFunction: func(
|
||||||
Usage: "Backup all services",
|
cmd *cobra.Command,
|
||||||
}
|
args []string,
|
||||||
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
var appBackupCommand = &cli.Command{
|
return autocomplete.AppNameComplete()
|
||||||
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.ABRA_DIR, "apps", 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
|
|
||||||
},
|
},
|
||||||
BashComplete: func(c *cli.Context) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
appNames, err := config.GetAppNames()
|
app := internal.ValidateApp(args)
|
||||||
|
|
||||||
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
121
cli/app/check.go
121
cli/app/check.go
@ -2,63 +2,90 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"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{
|
var AppCheckCommand = &cobra.Command{
|
||||||
Name: "check",
|
Use: "check <domain> [flags]",
|
||||||
Usage: "Check if app is configured correctly",
|
Aliases: []string{"chk"},
|
||||||
Aliases: []string{"c"},
|
Short: "Ensure an app is well configured",
|
||||||
ArgsUsage: "<service>",
|
Long: `Compare env vars in both the app ".env" and recipe ".env.sample" file.
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
|
|
||||||
envSamplePath := path.Join(config.ABRA_DIR, "apps", app.Type, ".env.sample")
|
The goal is to ensure that recipe ".env.sample" env vars are defined in your
|
||||||
if _, err := os.Stat(envSamplePath); err != nil {
|
app ".env" file. Only env var definitions in the ".env.sample" which are
|
||||||
if os.IsNotExist(err) {
|
uncommented, e.g. "FOO=bar" are checked. If an app ".env" file does not include
|
||||||
logrus.Fatalf("'%s' does not exist?", envSamplePath)
|
these env vars, then "check" will complain.
|
||||||
}
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
envSample, err := config.ReadEnv(envSamplePath)
|
Recipe maintainers may or may not provide defaults for env vars within their
|
||||||
if err != nil {
|
recipes regardless of commenting or not (e.g. through the use of
|
||||||
logrus.Fatal(err)
|
${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
|
||||||
}
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: func(
|
||||||
var missing []string
|
cmd *cobra.Command,
|
||||||
for k := range envSample {
|
args []string,
|
||||||
if _, ok := app.Env[k]; !ok {
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
missing = append(missing, k)
|
return autocomplete.AppNameComplete()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
},
|
||||||
BashComplete: func(c *cli.Context) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
appNames, err := config.GetAppNames()
|
app := internal.ValidateApp(args)
|
||||||
|
|
||||||
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
table, err := formatter.CreateTable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
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 _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,70 +1,57 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var appConfigCommand = &cli.Command{
|
var AppConfigCommand = &cobra.Command{
|
||||||
Name: "config",
|
Use: "config <domain> [flags]",
|
||||||
Aliases: []string{"c"},
|
Aliases: []string{"cfg"},
|
||||||
Usage: "Edit app config",
|
Short: "Edit app config",
|
||||||
Action: func(c *cli.Context) error {
|
Example: " abra config 1312.net",
|
||||||
appName := c.Args().First()
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: func(
|
||||||
if appName == "" {
|
cmd *cobra.Command,
|
||||||
internal.ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
args []string,
|
||||||
}
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return autocomplete.AppNameComplete()
|
||||||
files, err := config.LoadAppFiles("")
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
files, err := appPkg.LoadAppFiles("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appName := args[0]
|
||||||
appFile, exists := files[appName]
|
appFile, exists := files[appName]
|
||||||
if !exists {
|
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")
|
ed, ok := os.LookupEnv("EDITOR")
|
||||||
if !ok {
|
if !ok {
|
||||||
edPrompt := &survey.Select{
|
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"},
|
Options: []string{"vi", "vim", "nvim", "nano", "pico", "emacs"},
|
||||||
}
|
}
|
||||||
if err := survey.AskOne(edPrompt, &ed); err != nil {
|
if err := survey.AskOne(edPrompt, &ed); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(ed, appFile.Path)
|
c := exec.Command(ed, appFile.Path)
|
||||||
cmd.Stdin = os.Stdin
|
c.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
c.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
c.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := c.Run(); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
449
cli/app/cp.go
449
cli/app/cp.go
@ -1,124 +1,383 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||||
"github.com/docker/docker/api/types"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"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/docker/docker/pkg/archive"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var appCpCommand = &cli.Command{
|
var AppCpCommand = &cobra.Command{
|
||||||
Name: "cp",
|
Use: "cp <domain> <src> <dst> [flags]",
|
||||||
Aliases: []string{"c"},
|
Aliases: []string{"c"},
|
||||||
ArgsUsage: "<src> <dst>",
|
Short: "Copy files to/from a deployed app service",
|
||||||
Usage: "Copy files to/from a running app service",
|
Example: ` # copy myfile.txt to the root of the app service
|
||||||
Action: func(c *cli.Context) error {
|
abra app cp 1312.net myfile.txt app:/
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
|
|
||||||
src := c.Args().Get(1)
|
# copy that file back to your current working directory locally
|
||||||
dst := c.Args().Get(2)
|
abra app cp 1312.net app:/myfile.txt ./`,
|
||||||
if src == "" {
|
Args: cobra.ExactArgs(3),
|
||||||
logrus.Fatal("missing <src> argument")
|
ValidArgsFunction: func(
|
||||||
} else if dst == "" {
|
cmd *cobra.Command,
|
||||||
logrus.Fatal("missing <dest> argument")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedSrc := strings.SplitN(src, ":", 2)
|
src := args[1]
|
||||||
parsedDst := strings.SplitN(dst, ":", 2)
|
dst := args[2]
|
||||||
errorMsg := "one of <src>/<dest> arguments must take $SERVICE:$PATH form"
|
srcPath, dstPath, service, toContainer, err := parseSrcAndDst(src, dst)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
appFiles, err := config.LoadAppFiles("")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
|
|
||||||
appEnv, err := config.GetApp(appFiles, app.Name)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filters := filters.NewArgs()
|
container, err := containerPkg.GetContainerFromStackAndService(cl, app.StackName(), service)
|
||||||
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
|
|
||||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
|
||||||
|
|
||||||
if len(containers) != 1 {
|
if toContainer {
|
||||||
logrus.Fatalf("expected 1 container but got %v", len(containers))
|
err = CopyToContainer(cl, container.ID, srcPath, dstPath)
|
||||||
}
|
|
||||||
container := containers[0]
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
|
||||||
content, err := archive.TarWithOptions(srcPath, toTarOpts)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
|
||||||
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
|
err = CopyFromContainer(cl, container.ID, srcPath, dstPath)
|
||||||
if err != nil {
|
}
|
||||||
logrus.Fatal(err)
|
if err != nil {
|
||||||
}
|
log.Fatal(err)
|
||||||
defer content.Close()
|
|
||||||
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
|
||||||
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("local %s ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstStat, err := cl.ContainerStatPath(context.Background(), containerID, dstPath)
|
||||||
|
dstExists := true
|
||||||
|
if err != nil {
|
||||||
|
if errdefs.IsNotFound(err) {
|
||||||
|
dstExists = false
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("remote path: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,45 +1,354 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/app"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/envfile"
|
||||||
"github.com/urfave/cli/v2"
|
"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{
|
var AppDeployCommand = &cobra.Command{
|
||||||
Name: "deploy",
|
Use: "deploy <domain> [version] [flags]",
|
||||||
Aliases: []string{"d"},
|
Aliases: []string{"d"},
|
||||||
Usage: "Deploy an app",
|
Short: "Deploy an app",
|
||||||
Flags: []cli.Flag{
|
Long: `Deploy an app.
|
||||||
internal.ForceFlag,
|
|
||||||
internal.ChaosFlag,
|
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: `
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
This command deploys a new instance of an app. It does not support changing the
|
var (
|
||||||
version of an existing deployed app, for this you need to look at the "abra app
|
deployWarnMessages []string
|
||||||
upgrade <app>" command.
|
toDeployVersion string
|
||||||
|
)
|
||||||
|
|
||||||
You may pass "--force" to re-deploy the same version again. This can be useful
|
app := internal.ValidateApp(args)
|
||||||
if the container runtime has gotten into a weird state.
|
|
||||||
|
|
||||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
if err := validateArgsAndFlags(args); err != nil {
|
||||||
including unstaged changes and can be useful for live hacking and testing new
|
log.Fatal(err)
|
||||||
recipes.
|
}
|
||||||
`,
|
|
||||||
Action: internal.DeployAction,
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
BashComplete: func(c *cli.Context) {
|
log.Fatal(err)
|
||||||
appNames, err := config.GetAppNames()
|
}
|
||||||
|
|
||||||
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
358
cli/app/list.go
358
cli/app/list.go
@ -1,133 +1,166 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var status bool
|
type appStatus struct {
|
||||||
var statusFlag = &cli.BoolFlag{
|
Server string `json:"server"`
|
||||||
Name: "status",
|
Recipe string `json:"recipe"`
|
||||||
Aliases: []string{"S"},
|
AppName string `json:"appName"`
|
||||||
Value: false,
|
Domain string `json:"domain"`
|
||||||
Usage: "Show app deployment status",
|
Status string `json:"status"`
|
||||||
Destination: &status,
|
Chaos string `json:"chaos"`
|
||||||
|
ChaosVersion string `json:"chaosVersion"`
|
||||||
|
AutoUpdate string `json:"autoUpdate"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Upgrade string `json:"upgrade"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var appType string
|
type serverStatus struct {
|
||||||
var typeFlag = &cli.StringFlag{
|
Apps []appStatus `json:"apps"`
|
||||||
Name: "type",
|
AppCount int `json:"appCount"`
|
||||||
Aliases: []string{"t"},
|
VersionCount int `json:"versionCount"`
|
||||||
Value: "",
|
UnversionedCount int `json:"unversionedCount"`
|
||||||
Usage: "Show apps of a specific type",
|
LatestCount int `json:"latestCount"`
|
||||||
Destination: &appType,
|
UpgradeCount int `json:"upgradeCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var listAppServer string
|
var AppListCommand = &cobra.Command{
|
||||||
var listAppServerFlag = &cli.StringFlag{
|
Use: "list [flags]",
|
||||||
Name: "server",
|
|
||||||
Aliases: []string{"s"},
|
|
||||||
Value: "",
|
|
||||||
Usage: "Show apps of a specific server",
|
|
||||||
Destination: &listAppServer,
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
`,
|
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Flags: []cli.Flag{
|
Short: "List all managed apps",
|
||||||
statusFlag,
|
Long: `Generate a report of all managed apps.
|
||||||
listAppServerFlag,
|
|
||||||
typeFlag,
|
Use "--status/-S" flag to query all servers for the live deployment status.`,
|
||||||
},
|
Example: ` # list apps of all servers without live status
|
||||||
Action: func(c *cli.Context) error {
|
abra app ls
|
||||||
appFiles, err := config.LoadAppFiles(listAppServer)
|
|
||||||
|
# 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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
apps, err := config.GetApps(appFiles)
|
apps, err := appPkg.GetApps(appFiles, recipeFilter)
|
||||||
if err != nil {
|
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)
|
statuses := make(map[string]map[string]string)
|
||||||
tableCol := []string{"Server", "Type", "Domain"}
|
|
||||||
if status {
|
if status {
|
||||||
tableCol = append(tableCol, "Status", "Version", "Updates")
|
alreadySeen := make(map[string]bool)
|
||||||
statuses, err = config.GetAppStatuses(appFiles)
|
for _, app := range apps {
|
||||||
|
if _, ok := alreadySeen[app.Server]; !ok {
|
||||||
|
alreadySeen[app.Server] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err = appPkg.GetAppStatuses(apps, internal.MachineReadable)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
var totalServersCount int
|
||||||
table.SetAutoMergeCellsByColumnIndex([]int{0})
|
var totalAppsCount int
|
||||||
|
allStats := make(map[string]serverStatus)
|
||||||
var (
|
|
||||||
versionedAppsCount int
|
|
||||||
unversionedAppsCount int
|
|
||||||
onLatestCount int
|
|
||||||
canUpgradeCount int
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
var tableRow []string
|
var stats serverStatus
|
||||||
if app.Type == appType || appType == "" {
|
var ok bool
|
||||||
// If type flag is set, check for it, if not, Type == ""
|
if stats, ok = allStats[app.Server]; !ok {
|
||||||
tableRow = []string{app.Server, app.Type, app.Domain}
|
stats = serverStatus{}
|
||||||
|
if recipeFilter == "" {
|
||||||
|
// count server, no filtering
|
||||||
|
totalServersCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Recipe.Name == recipeFilter || recipeFilter == "" {
|
||||||
|
if recipeFilter != "" {
|
||||||
|
// only count server if matches filter
|
||||||
|
totalServersCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
appStats := appStatus{}
|
||||||
|
stats.AppCount++
|
||||||
|
totalAppsCount++
|
||||||
|
|
||||||
if status {
|
if status {
|
||||||
stackName := app.StackName()
|
|
||||||
status := "unknown"
|
status := "unknown"
|
||||||
version := "unknown"
|
version := "unknown"
|
||||||
if statusMeta, ok := statuses[stackName]; ok {
|
chaos := "unknown"
|
||||||
|
chaosVersion := "unknown"
|
||||||
|
autoUpdate := "unknown"
|
||||||
|
if statusMeta, ok := statuses[app.StackName()]; ok {
|
||||||
if currentVersion, exists := statusMeta["version"]; exists {
|
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"] != "" {
|
if statusMeta["status"] != "" {
|
||||||
status = statusMeta["status"]
|
status = statusMeta["status"]
|
||||||
}
|
}
|
||||||
tableRow = append(tableRow, status, version)
|
stats.VersionCount++
|
||||||
versionedAppsCount++
|
|
||||||
} else {
|
} else {
|
||||||
tableRow = append(tableRow, status, version)
|
stats.UnversionedCount++
|
||||||
unversionedAppsCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appStats.Status = status
|
||||||
|
appStats.Chaos = chaos
|
||||||
|
appStats.ChaosVersion = chaosVersion
|
||||||
|
appStats.Version = version
|
||||||
|
appStats.AutoUpdate = autoUpdate
|
||||||
|
|
||||||
var newUpdates []string
|
var newUpdates []string
|
||||||
if version != "unknown" {
|
if version != "unknown" && chaos == "false" {
|
||||||
updates, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatalf("unable to retrieve tags for %s: %s", app.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedVersion, err := tagcmp.Parse(version)
|
parsedVersion, err := tagcmp.Parse(version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, update := range updates {
|
for _, update := range updates {
|
||||||
parsedUpdate, err := tagcmp.Parse(update)
|
parsedUpdate, err := tagcmp.Parse(update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
||||||
@ -138,36 +171,159 @@ can take some time.
|
|||||||
|
|
||||||
if len(newUpdates) == 0 {
|
if len(newUpdates) == 0 {
|
||||||
if version == "unknown" {
|
if version == "unknown" {
|
||||||
tableRow = append(tableRow, "unknown")
|
appStats.Upgrade = "unknown"
|
||||||
} else {
|
} else {
|
||||||
tableRow = append(tableRow, "on latest")
|
appStats.Upgrade = "latest"
|
||||||
onLatestCount++
|
stats.LatestCount++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// FIXME: jeezus golang why do you not have a list reverse function
|
newUpdates = internal.SortVersionsDesc(newUpdates)
|
||||||
for i, j := 0, len(newUpdates)-1; i < j; i, j = i+1, j-1 {
|
appStats.Upgrade = strings.Join(newUpdates, "\n")
|
||||||
newUpdates[i], newUpdates[j] = newUpdates[j], newUpdates[i]
|
stats.UpgradeCount++
|
||||||
}
|
|
||||||
tableRow = append(tableRow, strings.Join(newUpdates, "\n"))
|
|
||||||
canUpgradeCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appStats.Server = app.Server
|
||||||
|
appStats.Recipe = app.Recipe.Name
|
||||||
|
appStats.AppName = app.Name
|
||||||
|
appStats.Domain = app.Domain
|
||||||
|
|
||||||
|
stats.Apps = append(stats.Apps, appStats)
|
||||||
}
|
}
|
||||||
table.Append(tableRow)
|
allStats[app.Server] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := fmt.Sprintf(
|
if internal.MachineReadable {
|
||||||
"Total apps: %v | Versioned: %v | Unversioned: %v | On latest: %v | Can upgrade: %v",
|
jsonstring, err := json.Marshal(allStats)
|
||||||
len(apps),
|
if err != nil {
|
||||||
versionedAppsCount,
|
log.Fatal(err)
|
||||||
unversionedAppsCount,
|
} else {
|
||||||
onLatestCount,
|
fmt.Println(string(jsonstring))
|
||||||
canUpgradeCount,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
table.SetCaption(true, stats)
|
return
|
||||||
table.Render()
|
}
|
||||||
|
|
||||||
return nil
|
alreadySeen := make(map[string]bool)
|
||||||
|
for _, app := range apps {
|
||||||
|
if _, ok := alreadySeen[app.Server]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverStat := allStats[app.Server]
|
||||||
|
|
||||||
|
headers := []string{"RECIPE", "DOMAIN", "SERVER"}
|
||||||
|
if status {
|
||||||
|
headers = append(headers, []string{
|
||||||
|
"STATUS",
|
||||||
|
"CHAOS",
|
||||||
|
"VERSION",
|
||||||
|
"UPGRADE",
|
||||||
|
"AUTOUPDATE"}...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
table, err := formatter.CreateTable()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Headers(headers...)
|
||||||
|
|
||||||
|
var rows [][]string
|
||||||
|
for _, appStat := range serverStat.Apps {
|
||||||
|
row := []string{appStat.Recipe, appStat.Domain, appStat.Server}
|
||||||
|
if status {
|
||||||
|
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}...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Rows(rows...)
|
||||||
|
|
||||||
|
if len(rows) > 0 {
|
||||||
|
if err := formatter.PrintTable(table); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allStats) > 1 && len(rows) > 0 {
|
||||||
|
fmt.Println() // newline separator for multiple servers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadySeen[app.Server] = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
175
cli/app/logs.go
175
cli/app/logs.go
@ -1,124 +1,107 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"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/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/docker/docker/api/types"
|
"coopcloud.tech/abra/pkg/logs"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
dockerClient "github.com/docker/docker/client"
|
"github.com/spf13/cobra"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// stackLogs lists logs for all stack services
|
var AppLogsCommand = &cobra.Command{
|
||||||
func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
|
Use: "logs <domain> [service] [flags]",
|
||||||
filters := filters.NewArgs()
|
Aliases: []string{"l"},
|
||||||
filters.Add("name", stackName)
|
Short: "Tail app logs",
|
||||||
serviceOpts := types.ServiceListOptions{Filters: filters}
|
Args: cobra.RangeArgs(1, 2),
|
||||||
services, err := client.ServiceList(c.Context, serviceOpts)
|
ValidArgsFunction: func(
|
||||||
if err != nil {
|
cmd *cobra.Command,
|
||||||
logrus.Fatal(err)
|
args []string,
|
||||||
}
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
switch l := len(args); l {
|
||||||
var wg sync.WaitGroup
|
case 0:
|
||||||
for _, service := range services {
|
return autocomplete.AppNameComplete()
|
||||||
wg.Add(1)
|
case 1:
|
||||||
go func(s string) {
|
app, err := appPkg.Get(args[0])
|
||||||
logOpts := types.ContainerLogsOptions{
|
|
||||||
Details: true,
|
|
||||||
Follow: true,
|
|
||||||
ShowStderr: true,
|
|
||||||
ShowStdout: true,
|
|
||||||
Tail: "20",
|
|
||||||
Timestamps: true,
|
|
||||||
}
|
|
||||||
logs, err := client.ServiceLogs(c.Context, s, logOpts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||||
|
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||||
}
|
}
|
||||||
// defer after err check as any err returns a nil io.ReadCloser
|
return autocomplete.ServiceNameComplete(app.Name)
|
||||||
defer logs.Close()
|
default:
|
||||||
|
return nil, cobra.ShellCompDirectiveDefault
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
app := internal.ValidateApp(args)
|
||||||
|
stackName := app.StackName()
|
||||||
|
|
||||||
_, err = io.Copy(os.Stdout, logs)
|
if err := app.Recipe.EnsureExists(); err != nil {
|
||||||
if err != nil && err != io.EOF {
|
log.Fatal(err)
|
||||||
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",
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := c.Args().Get(1)
|
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||||
if serviceName == "" {
|
|
||||||
logrus.Debug("tailing logs for all app services")
|
|
||||||
stackLogs(c, app.StackName(), cl)
|
|
||||||
}
|
|
||||||
logrus.Debugf("tailing logs for '%s'", serviceName)
|
|
||||||
|
|
||||||
service := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
|
||||||
filters := filters.NewArgs()
|
|
||||||
filters.Add("name", service)
|
|
||||||
serviceOpts := types.ServiceListOptions{Filters: filters}
|
|
||||||
services, err := cl.ServiceList(c.Context, serviceOpts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
if len(services) != 1 {
|
|
||||||
logrus.Fatalf("expected 1 service but got %v", len(services))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logOpts := types.ContainerLogsOptions{
|
if !deployMeta.IsDeployed {
|
||||||
Details: true,
|
log.Fatalf("%s is not deployed?", app.Name)
|
||||||
Follow: true,
|
|
||||||
ShowStderr: true,
|
|
||||||
ShowStdout: true,
|
|
||||||
Tail: "20",
|
|
||||||
Timestamps: true,
|
|
||||||
}
|
}
|
||||||
logs, err := cl.ServiceLogs(c.Context, services[0].ID, logOpts)
|
|
||||||
|
var serviceNames []string
|
||||||
|
if len(args) == 2 {
|
||||||
|
serviceNames = []string{args[1]}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := app.Filters(true, false, serviceNames...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
// defer after err check as any err returns a nil io.ReadCloser
|
|
||||||
defer logs.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(os.Stdout, logs)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
opts := logs.TailOpts{
|
||||||
},
|
AppName: app.Name,
|
||||||
BashComplete: func(c *cli.Context) {
|
Services: serviceNames,
|
||||||
appNames, err := config.GetAppNames()
|
StdErr: stdErr,
|
||||||
if err != nil {
|
Since: sinceLogs,
|
||||||
logrus.Warn(err)
|
Filters: f,
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
if err := logs.TailLogs(cl, opts); err != nil {
|
||||||
}
|
log.Fatal(err)
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
stdErr bool
|
||||||
|
sinceLogs string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
AppLogsCommand.Flags().BoolVarP(
|
||||||
|
&stdErr,
|
||||||
|
"stderr",
|
||||||
|
"s",
|
||||||
|
false,
|
||||||
|
"only tail stderr",
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
387
cli/app/new.go
387
cli/app/new.go
@ -4,21 +4,34 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/app"
|
||||||
"github.com/sirupsen/logrus"
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
"github.com/urfave/cli/v2"
|
"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"
|
||||||
|
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 = `
|
var appNewDescription = `Creates a new app from a default recipe.
|
||||||
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.
|
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
|
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".
|
by running "abra recipe ls".
|
||||||
|
|
||||||
|
Recipe commit hashes are supported values for "[version]".
|
||||||
|
|
||||||
Passing the "--secrets/-S" flag will automatically generate secrets for your
|
Passing the "--secrets/-S" flag will automatically generate secrets for your
|
||||||
app and store them encrypted at rest on the chosen target server. These
|
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
|
generated secrets are only visible at generation time, so please take care to
|
||||||
@ -26,33 +39,345 @@ store them somewhere safe.
|
|||||||
|
|
||||||
You can use the "--pass/-P" to store these generated passwords locally in a
|
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
|
pass store (see passwordstore.org for more). The pass command must be available
|
||||||
on your $PATH.
|
on your $PATH.`
|
||||||
`
|
|
||||||
|
|
||||||
var appNewCommand = &cli.Command{
|
var AppNewCommand = &cobra.Command{
|
||||||
Name: "new",
|
Use: "new [recipe] [version] [flags]",
|
||||||
Usage: "Create a new app",
|
Aliases: []string{"n"},
|
||||||
Aliases: []string{"n"},
|
Short: "Create a new app",
|
||||||
Description: appNewDescription,
|
Long: appNewDescription,
|
||||||
Flags: []cli.Flag{
|
Args: cobra.RangeArgs(0, 2),
|
||||||
internal.NewAppServerFlag,
|
ValidArgsFunction: func(
|
||||||
internal.DomainFlag,
|
cmd *cobra.Command,
|
||||||
internal.NewAppNameFlag,
|
args []string,
|
||||||
internal.PassFlag,
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
internal.SecretsFlag,
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
ArgsUsage: "<recipe>",
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
Action: internal.NewAction,
|
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
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 {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
if err := app.WriteRecipeVersion(recipeVersion, false); err != nil {
|
||||||
}
|
log.Fatalf("writing recipe version failed: %s", err)
|
||||||
for name := range catl {
|
|
||||||
fmt.Println(name)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
235
cli/app/ps.go
235
cli/app/ps.go
@ -1,99 +1,210 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"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/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"github.com/docker/cli/cli/command/formatter"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"github.com/docker/docker/api/types"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
abraService "coopcloud.tech/abra/pkg/service"
|
||||||
|
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
|
dockerFormatter "github.com/docker/cli/cli/command/formatter"
|
||||||
|
containerTypes "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/sirupsen/logrus"
|
dockerClient "github.com/docker/docker/client"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var watch bool
|
var AppPsCommand = &cobra.Command{
|
||||||
var watchFlag = &cli.BoolFlag{
|
Use: "ps <domain> [flags]",
|
||||||
Name: "watch",
|
|
||||||
Aliases: []string{"w"},
|
|
||||||
Value: false,
|
|
||||||
Usage: "Watch status by polling repeatedly",
|
|
||||||
Destination: &watch,
|
|
||||||
}
|
|
||||||
|
|
||||||
var appPsCommand = &cli.Command{
|
|
||||||
Name: "ps",
|
|
||||||
Usage: "Check app status",
|
|
||||||
Aliases: []string{"p"},
|
Aliases: []string{"p"},
|
||||||
Flags: []cli.Flag{
|
Short: "Check app deployment status",
|
||||||
watchFlag,
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: func(
|
||||||
|
cmd *cobra.Command,
|
||||||
|
args []string,
|
||||||
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return autocomplete.AppNameComplete()
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if !watch {
|
app := internal.ValidateApp(args)
|
||||||
showPSOutput(c)
|
|
||||||
return nil
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: how do we make this update in-place in an x-platform way?
|
cl, err := client.New(app.Server)
|
||||||
for {
|
|
||||||
showPSOutput(c)
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
if !deployMeta.IsDeployed {
|
||||||
|
log.Fatalf("%s is not deployed?", app.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showPSOutput(app, cl, deployMeta.Version, chaosVersion)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// showPSOutput renders ps output.
|
// showPSOutput renders ps output.
|
||||||
func showPSOutput(c *cli.Context) {
|
func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chaosVersion string) {
|
||||||
app := internal.ValidateApp(c)
|
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filters := filters.NewArgs()
|
deployOpts := stack.Deploy{
|
||||||
filters.Add("name", app.StackName())
|
Composefiles: composeFiles,
|
||||||
|
Namespace: app.StackName(),
|
||||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
Prune: false,
|
||||||
|
ResolveImage: stack.ResolveImageAlways,
|
||||||
|
}
|
||||||
|
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tableCol := []string{"image", "created", "status", "ports", "names"}
|
services := compose.Services
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
sort.Slice(services, func(i, j int) bool {
|
||||||
|
return services[i].Name < services[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
for _, container := range containers {
|
var rows [][]string
|
||||||
var containerNames []string
|
allContainerStats := make(map[string]map[string]string)
|
||||||
for _, containerName := range container.Names {
|
for _, service := range services {
|
||||||
trimmed := strings.TrimPrefix(containerName, "/")
|
filters := filters.NewArgs()
|
||||||
containerNames = append(containerNames, trimmed)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
tableRow := []string{
|
var containerStats map[string]string
|
||||||
abraFormatter.RemoveSha(container.Image),
|
if len(containers) == 0 {
|
||||||
abraFormatter.HumanDuration(container.Created),
|
containerStats = map[string]string{
|
||||||
container.Status,
|
"version": deployedVersion,
|
||||||
formatter.DisplayablePorts(container.Ports),
|
"chaos": chaosVersion,
|
||||||
strings.Join(containerNames, "\n"),
|
"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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
table.Append(tableRow)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Render()
|
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,78 +1,102 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/spf13/cobra"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Volumes stores the variable from VolumesFlag
|
var AppRemoveCommand = &cobra.Command{
|
||||||
var Volumes bool
|
Use: "remove <domain> [flags]",
|
||||||
|
|
||||||
// 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",
|
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Flags: []cli.Flag{
|
Short: "Remove all app data, locally and remotely",
|
||||||
VolumesFlag,
|
Long: `Remove everything related to an app which is already undeployed.
|
||||||
internal.ForceFlag,
|
|
||||||
|
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()
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
app := internal.ValidateApp(c)
|
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
|
response := false
|
||||||
prompt := &survey.Confirm{
|
prompt := &survey.Confirm{Message: "are you sure?"}
|
||||||
Message: fmt.Sprintf("about to delete %s, are you sure?", app.Name),
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &response); err != nil {
|
if err := survey.AskOne(prompt, &response); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if !response {
|
|
||||||
logrus.Fatal("user aborted app removal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
appFiles, err := config.LoadAppFiles("")
|
if !response {
|
||||||
if err != nil {
|
log.Fatal("aborting as requested")
|
||||||
logrus.Fatal(err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
if !internal.Force {
|
|
||||||
// FIXME: only query for app we are interested in, not all of them!
|
|
||||||
statuses, err := config.GetAppStatuses(appFiles)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
if statuses[app.Name]["status"] == "deployed" {
|
|
||||||
logrus.Fatalf("'%s' is still deployed. Run \"abra app %s undeploy\" or pass --force", app.Name, app.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs := filters.NewArgs()
|
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||||
fs.Add("name", app.Name)
|
|
||||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: fs})
|
|
||||||
if err != nil {
|
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)
|
secrets := make(map[string]string)
|
||||||
@ -84,85 +108,53 @@ var appRemoveCommand = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(secrets) > 0 {
|
if len(secrets) > 0 {
|
||||||
var secretNamesToRemove []string
|
for _, name := range secretNames {
|
||||||
if !internal.Force {
|
err := cl.SecretRemove(context.Background(), secrets[name])
|
||||||
secretsPrompt := &survey.MultiSelect{
|
|
||||||
Message: "which secrets do you want to remove?",
|
|
||||||
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])
|
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
logrus.Info("no secrets to remove")
|
log.Info("no secrets to remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
volumeListOKBody, err := cl.VolumeList(c.Context, fs)
|
fs, err = app.Filters(false, true)
|
||||||
volumeList := volumeListOKBody.Volumes
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var vols []string
|
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, fs)
|
||||||
for _, vol := range volumeList {
|
if err != nil {
|
||||||
vols = append(vols, vol.Name)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
volumeNames := client.GetVolumeNames(volumeList)
|
||||||
|
|
||||||
if len(vols) > 0 {
|
if len(volumeNames) > 0 {
|
||||||
if Volumes {
|
err := client.RemoveVolumes(cl, context.Background(), volumeNames, internal.Force, 5)
|
||||||
var removeVols []string
|
if err != nil {
|
||||||
if !internal.Force {
|
log.Fatalf("removing volumes failed: %s", err)
|
||||||
volumesPrompt := &survey.MultiSelect{
|
|
||||||
Message: "which volumes do you want to remove?",
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("%d volume(s) removed successfully", len(volumeNames))
|
||||||
} else {
|
} else {
|
||||||
logrus.Info("no volumes to remove")
|
log.Info("no volumes to remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.Remove(app.Path)
|
if err = os.Remove(app.Path); err != nil {
|
||||||
if err != nil {
|
log.Fatal(err)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
logrus.Info(fmt.Sprintf("file: %s removed", app.Path))
|
|
||||||
|
|
||||||
return nil
|
log.Info(fmt.Sprintf("file: %s removed", app.Path))
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
AppRemoveCommand.Flags().BoolVarP(
|
||||||
|
&internal.Force,
|
||||||
|
"force",
|
||||||
|
"f",
|
||||||
|
false,
|
||||||
|
"perform action without further prompt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
165
cli/app/restart.go
Normal file
165
cli/app/restart.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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/log"
|
||||||
|
"coopcloud.tech/abra/pkg/ui"
|
||||||
|
upstream "coopcloud.tech/abra/pkg/upstream/service"
|
||||||
|
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
stackServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"github.com/urfave/cli/v2"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var restoreAllServices bool
|
var AppRestoreCommand = &cobra.Command{
|
||||||
var restoreAllServicesFlag = &cli.BoolFlag{
|
Use: "restore <domain> [flags]",
|
||||||
Name: "all",
|
Aliases: []string{"rs"},
|
||||||
Value: false,
|
Short: "Restore a snapshot",
|
||||||
Destination: &restoreAllServices,
|
Long: `Snapshots are restored while apps are deployed.
|
||||||
Aliases: []string{"a"},
|
|
||||||
Usage: "Restore all services",
|
|
||||||
}
|
|
||||||
|
|
||||||
var appRestoreCommand = &cli.Command{
|
Some restore scenarios may require service / app restarts.`,
|
||||||
Name: "restore",
|
Args: cobra.ExactArgs(1),
|
||||||
Usage: "Restore an app from a backup",
|
ValidArgsFunction: func(
|
||||||
Aliases: []string{"r"},
|
cmd *cobra.Command,
|
||||||
Flags: []cli.Flag{restoreAllServicesFlag},
|
args []string,
|
||||||
ArgsUsage: "<service> [<backup file>]",
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
Action: func(c *cli.Context) error {
|
return autocomplete.AppNameComplete()
|
||||||
app := internal.ValidateApp(c)
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
app := internal.ValidateApp(args)
|
||||||
|
|
||||||
if c.Args().Len() > 1 && restoreAllServices {
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together"))
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
abraSh := path.Join(config.ABRA_DIR, "apps", app.Type, "abra.sh")
|
cl, err := client.New(app.Server)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
if !strings.Contains(string(bytes), execCmd) {
|
|
||||||
logrus.Fatalf("%s doesn't have a '%s' function", app.Type, execCmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backupFile := c.Args().Get(2)
|
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
|
||||||
if backupFile != "" {
|
if err != nil {
|
||||||
execCmd = fmt.Sprintf("%s %s", execCmd, backupFile)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)
|
execEnv := []string{
|
||||||
cmd := exec.Command("bash", "-c", sourceAndExec)
|
fmt.Sprintf("SERVICE=%s", app.Domain),
|
||||||
if err := internal.RunCmd(cmd); err != nil {
|
"MACHINE_LOGS=true",
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,175 +3,341 @@ package app
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"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/config"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
"coopcloud.tech/abra/pkg/envfile"
|
||||||
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/lint"
|
||||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/tagcmp"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var appRollbackCommand = &cli.Command{
|
var AppRollbackCommand = &cobra.Command{
|
||||||
Name: "rollback",
|
Use: "rollback <domain> [version] [flags]",
|
||||||
Usage: "Roll an app back to a previous version",
|
Aliases: []string{"rl"},
|
||||||
Aliases: []string{"r", "downgrade"},
|
Short: "Roll an app back to a previous version",
|
||||||
ArgsUsage: "<app>",
|
Long: `This command rolls an app back to a previous version.
|
||||||
Flags: []cli.Flag{
|
|
||||||
internal.ForceFlag,
|
|
||||||
internal.ChaosFlag,
|
|
||||||
},
|
|
||||||
Description: `
|
|
||||||
This command rolls an app back to a previous version if one exists.
|
|
||||||
|
|
||||||
You may pass "--force/-f" to downgrade to the same version again. This can be
|
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
|
||||||
useful if the container runtime has gotten into a weird state.
|
versions are supported values for "[version]".
|
||||||
|
|
||||||
This action could be destructive, please ensure you have a copy of your app
|
It is possible to "--force/-f" an downgrade if you want to re-deploy a specific
|
||||||
data beforehand - see "abra app backup <app>" for more.
|
version.
|
||||||
|
|
||||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
Only the deployed version is consulted when trying to determine what downgrades
|
||||||
including unstaged changes and can be useful for live hacking and testing new
|
are available. The live deployment version is the "source of truth" in this
|
||||||
recipes.
|
case. The stored .env version is not consulted.
|
||||||
`,
|
|
||||||
BashComplete: func(c *cli.Context) {
|
A downgrade can be destructive, please ensure you have a copy of your app data
|
||||||
appNames, err := config.GetAppNames()
|
beforehand. See "abra app backup" for more.`,
|
||||||
if err != nil {
|
Example: ` # standard rollback
|
||||||
logrus.Warn(err)
|
abra app rollback 1312.net
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
# rollback to specific version
|
||||||
return
|
abra app rollback 1312.net 2.0.0+1.2.3`,
|
||||||
}
|
Args: cobra.RangeArgs(1, 2),
|
||||||
for _, a := range appNames {
|
ValidArgsFunction: func(
|
||||||
fmt.Println(a)
|
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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
app := internal.ValidateApp(c)
|
var (
|
||||||
stackName := app.StackName()
|
downgradeWarnMessages []string
|
||||||
|
chosenDowngrade string
|
||||||
|
availableDowngrades []string
|
||||||
|
)
|
||||||
|
|
||||||
|
app := internal.ValidateApp(args)
|
||||||
|
|
||||||
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
deployMeta, err := ensureDeployed(cl, app)
|
||||||
|
|
||||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDeployed {
|
if err := lint.LintForErrors(app.Recipe); err != nil {
|
||||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
versions, err := app.Recipe.Tags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableDowngrades []string
|
// NOTE(d1): we've no idea what the live deployment version is, so every
|
||||||
if deployedVersion == "" {
|
// possible downgrade can be shown. it's up to the user to make the choice
|
||||||
deployedVersion = "unknown"
|
if deployMeta.Version == config.UNKNOWN_DEFAULT {
|
||||||
availableDowngrades = versions
|
availableDowngrades = versions
|
||||||
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if deployedVersion != "unknown" && !internal.Chaos {
|
if len(args) == 2 && args[1] != "" {
|
||||||
for _, version := range versions {
|
chosenDowngrade = args[1]
|
||||||
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
|
|
||||||
if err != nil {
|
if err := validateDowngradeVersionArg(chosenDowngrade, app, deployMeta); err != nil {
|
||||||
logrus.Fatal(err)
|
log.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(availableDowngrades) == 0 {
|
availableDowngrades = append(availableDowngrades, chosenDowngrade)
|
||||||
logrus.Fatal("no available downgrades, you're on latest")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: jeezus golang why do you not have a list reverse function
|
if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenDowngrade == "" {
|
||||||
for i, j := 0, len(availableDowngrades)-1; i < j; i, j = i+1, j-1 {
|
downgradeAvailable, err := ensureDowngradesAvailable(versions, &availableDowngrades, deployMeta)
|
||||||
availableDowngrades[i], availableDowngrades[j] = availableDowngrades[j], availableDowngrades[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !downgradeAvailable {
|
||||||
|
log.Info("no available downgrades")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
|
if internal.Force || internal.NoInput || chosenDowngrade != "" {
|
||||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
for k, v := range abraShEnv {
|
for k, v := range abraShEnv {
|
||||||
app.Env[k] = v
|
app.Env[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stackName := app.StackName()
|
||||||
deployOpts := stack.Deploy{
|
deployOpts := stack.Deploy{
|
||||||
Composefiles: composeFiles,
|
Composefiles: composeFiles,
|
||||||
Namespace: stackName,
|
Namespace: stackName,
|
||||||
Prune: false,
|
Prune: false,
|
||||||
ResolveImage: stack.ResolveImageAlways,
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !internal.Force {
|
appPkg.ExposeAllEnv(stackName, compose, app.Env)
|
||||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
|
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
|
||||||
logrus.Fatal(err)
|
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); err != nil {
|
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
|
||||||
logrus.Fatal(err)
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
152
cli/app/run.go
152
cli/app/run.go
@ -1,128 +1,114 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/abra/pkg/upstream/container"
|
"coopcloud.tech/abra/pkg/upstream/container"
|
||||||
"github.com/docker/cli/cli/command"
|
"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/docker/docker/api/types/filters"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var user string
|
var AppRunCommand = &cobra.Command{
|
||||||
var userFlag = &cli.StringFlag{
|
Use: "run <domain> <service> <cmd> [[args] [flags] | [flags] -- [args]]",
|
||||||
Name: "user",
|
Aliases: []string{"r"},
|
||||||
Value: "",
|
Short: "Run a command inside a service container",
|
||||||
Destination: &user,
|
Example: ` # run <cmd> with args/flags
|
||||||
}
|
abra app run 1312.net app -- ls -lha
|
||||||
|
|
||||||
var noTTY bool
|
# run <cmd> without args/flags
|
||||||
var noTTYFlag = &cli.BoolFlag{
|
abra app run 1312.net app bash --user nobody
|
||||||
Name: "no-tty",
|
|
||||||
Value: false,
|
|
||||||
Destination: &noTTY,
|
|
||||||
}
|
|
||||||
|
|
||||||
var appRunCommand = &cli.Command{
|
# run <cmd> with both kinds of args/flags
|
||||||
Name: "run",
|
abra app run 1312.net app --user nobody -- ls -lha`,
|
||||||
Flags: []cli.Flag{
|
Args: cobra.MinimumNArgs(3),
|
||||||
noTTYFlag,
|
ValidArgsFunction: func(
|
||||||
userFlag,
|
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"},
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
ArgsUsage: "<service> <args>...",
|
app := internal.ValidateApp(args)
|
||||||
Usage: "Run a command in a service container",
|
|
||||||
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?"))
|
|
||||||
}
|
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := c.Args().Get(1)
|
serviceName := args[1]
|
||||||
stackAndServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), serviceName)
|
||||||
|
|
||||||
filters := filters.NewArgs()
|
filters := filters.NewArgs()
|
||||||
filters.Add("name", stackAndServiceName)
|
filters.Add("name", stackAndServiceName)
|
||||||
|
|
||||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(containers) == 0 {
|
userCmd := args[2:]
|
||||||
logrus.Fatalf("no containers matching '%s' found?", stackAndServiceName)
|
execCreateOpts := containertypes.ExecOptions{
|
||||||
}
|
|
||||||
if len(containers) > 1 {
|
|
||||||
logrus.Fatalf("expected 1 container matching '%s' but got %d", stackAndServiceName, len(containers))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := c.Args().Slice()[2:]
|
|
||||||
execCreateOpts := types.ExecConfig{
|
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
AttachStdin: true,
|
AttachStdin: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
Cmd: cmd,
|
Cmd: userCmd,
|
||||||
Detach: false,
|
Detach: false,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if user != "" {
|
if runAsUser != "" {
|
||||||
execCreateOpts.User = user
|
execCreateOpts.User = runAsUser
|
||||||
}
|
}
|
||||||
if noTTY {
|
if noTTY {
|
||||||
execCreateOpts.Tty = false
|
execCreateOpts.Tty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: an absolutely monumental hack to instantiate another command-line
|
|
||||||
// client withing our command-line client so that we pass something down
|
|
||||||
// the tubes that satisfies the necessary interface requirements. We should
|
|
||||||
// refactor our vendored container code to not require all this cruft. For
|
|
||||||
// now, It Works.
|
|
||||||
dcli, err := command.NewDockerCli()
|
dcli, err := command.NewDockerCli()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := container.RunExec(dcli, cl, containers[0].ID, &execCreateOpts); err != nil {
|
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
switch c.NArg() {
|
|
||||||
case 0:
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
appName := c.Args().First()
|
|
||||||
serviceNames, err := config.GetAppServiceNames(appName)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
for _, s := range serviceNames {
|
|
||||||
fmt.Println(s)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"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/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/abra/pkg/secret"
|
"coopcloud.tech/abra/pkg/secret"
|
||||||
"github.com/docker/docker/api/types"
|
"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/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var allSecrets bool
|
var AppSecretGenerateCommand = &cobra.Command{
|
||||||
var allSecretsFlag = &cli.BoolFlag{
|
Use: "generate <domain> [[secret] [version] | --all] [flags]",
|
||||||
Name: "all",
|
Aliases: []string{"g"},
|
||||||
Aliases: []string{"A"},
|
Short: "Generate secrets",
|
||||||
Value: false,
|
Args: cobra.RangeArgs(1, 3),
|
||||||
Destination: &allSecrets,
|
ValidArgsFunction: func(
|
||||||
Usage: "Generate all secrets",
|
cmd *cobra.Command,
|
||||||
}
|
args []string,
|
||||||
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
var appSecretGenerateCommand = &cli.Command{
|
switch l := len(args); l {
|
||||||
Name: "generate",
|
case 0:
|
||||||
Aliases: []string{"g"},
|
return autocomplete.AppNameComplete()
|
||||||
Usage: "Generate secrets",
|
case 1:
|
||||||
ArgsUsage: "<secret> <version>",
|
app, err := appPkg.Get(args[0])
|
||||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
if err != nil {
|
||||||
Action: func(c *cli.Context) error {
|
errMsg := fmt.Sprintf("autocomplete failed: %s", err)
|
||||||
app := internal.ValidateApp(c)
|
return []string{errMsg}, cobra.ShellCompDirectiveError
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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 {
|
for name, data := range secretVals {
|
||||||
if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
|
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(secretVals) == 0 {
|
if len(secretVals) == 0 {
|
||||||
logrus.Warn("no secrets generated")
|
log.Warn("no secrets generated")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
tableCol := []string{"name", "value"}
|
headers := []string{"NAME", "VALUE"}
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
table, err := formatter.CreateTable()
|
||||||
for name, val := range secretVals {
|
if err != nil {
|
||||||
table.Append([]string{name, val})
|
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{
|
var AppSecretInsertCommand = &cobra.Command{
|
||||||
Name: "insert",
|
Use: "insert <domain> <secret> <version> <data> [flags]",
|
||||||
Aliases: []string{"i"},
|
Aliases: []string{"i"},
|
||||||
Usage: "Insert secret",
|
Short: "Insert secret",
|
||||||
Flags: []cli.Flag{internal.PassFlag},
|
Long: `This command inserts a secret into an app environment.
|
||||||
ArgsUsage: "<app> <secret-name> <version> <data>",
|
|
||||||
Description: `
|
Arbitrary secret insertion is not supported. Secrets that are inserted must
|
||||||
This command inserts a secret into an app environment.
|
match those configured in the recipe beforehand.
|
||||||
|
|
||||||
This can be useful when you want to manually generate secrets for an app
|
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
|
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
|
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
`,
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
|
|
||||||
if c.Args().Len() != 4 {
|
|
||||||
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
name := c.Args().Get(1)
|
cl, err := client.New(app.Server)
|
||||||
version := c.Args().Get(2)
|
if err != nil {
|
||||||
data := c.Args().Get(3)
|
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)
|
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
|
||||||
if err := client.StoreSecret(secretName, data, app.Server); err != nil {
|
if err := client.StoreSecret(cl, secretName, data); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if internal.Pass {
|
log.Infof("%s successfully stored on server", secretName)
|
||||||
if err := secret.PassInsertSecret(data, name, app.StackName(), app.Server); err != nil {
|
|
||||||
logrus.Fatal(err)
|
if storeInPass {
|
||||||
|
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var appSecretRmCommand = &cli.Command{
|
// secretRm removes a secret.
|
||||||
Name: "remove",
|
func secretRm(cl *dockerClient.Client, app appPkg.App, secretName, parsed string) error {
|
||||||
Usage: "Remove a secret",
|
if err := cl.SecretRemove(context.Background(), secretName); err != nil {
|
||||||
Aliases: []string{"rm"},
|
return err
|
||||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
}
|
||||||
ArgsUsage: "<app> <secret-name>",
|
|
||||||
Description: `
|
|
||||||
This command removes a secret from an app environment.
|
|
||||||
|
|
||||||
Example:
|
log.Infof("deleted %s successfully from server", secretName)
|
||||||
|
|
||||||
abra app secret remove myapp db_pass
|
if removeFromPass {
|
||||||
|
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||||
`,
|
return err
|
||||||
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 c.Args().Get(1) == "" && !allSecrets {
|
log.Infof("deleted %s successfully from local pass store", secretName)
|
||||||
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
|
}
|
||||||
|
|
||||||
|
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)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filters := filters.NewArgs()
|
filters, err := app.Filters(false, false)
|
||||||
filters.Add("name", app.StackName())
|
|
||||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secretToRm := c.Args().Get(1)
|
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
|
||||||
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 := abraFormatter.CreateTable(tableCol)
|
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
|
||||||
if err != nil {
|
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})
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteSecretNames := make(map[string]bool)
|
remoteSecretNames := make(map[string]bool)
|
||||||
@ -234,47 +333,230 @@ var appSecretLsCommand = &cli.Command{
|
|||||||
remoteSecretNames[cont.Spec.Annotations.Name] = true
|
remoteSecretNames[cont.Spec.Annotations.Name] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for sec := range secrets {
|
var secretToRm string
|
||||||
createdRemote := false
|
if len(args) == 2 {
|
||||||
secretName := secret.ParseSecretEnvVarName(sec)
|
secretToRm = args[1]
|
||||||
secVal, err := secret.ParseSecretEnvVarValue(secrets[sec])
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, secVal.Version)
|
|
||||||
if _, ok := remoteSecretNames[secretRemoteName]; ok {
|
|
||||||
createdRemote = true
|
|
||||||
}
|
|
||||||
tableRow := []string{secretName, secVal.Version, secretRemoteName, strconv.FormatBool(createdRemote)}
|
|
||||||
table.Append(tableRow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Render()
|
match := false
|
||||||
return nil
|
for secretName, val := range secrets {
|
||||||
},
|
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version)
|
||||||
BashComplete: func(c *cli.Context) {
|
if _, ok := remoteSecretNames[secretRemoteName]; ok {
|
||||||
appNames, err := config.GetAppNames()
|
if secretToRm != "" {
|
||||||
if err != nil {
|
if secretName == secretToRm {
|
||||||
logrus.Warn(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
|
if !match && secretToRm != "" {
|
||||||
|
log.Fatalf("%s doesn't exist on server?", secretToRm)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
log.Fatal("no secrets to remove?")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
log.Warnf("no secrets stored for %s", app.Name)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var appSecretCommand = &cli.Command{
|
var AppSecretCommand = &cobra.Command{
|
||||||
Name: "secret",
|
Use: "secret [cmd] [args] [flags]",
|
||||||
Aliases: []string{"s"},
|
Aliases: []string{"s"},
|
||||||
Usage: "Manage app secrets",
|
Short: "Manage app secrets",
|
||||||
ArgsUsage: "<command>",
|
}
|
||||||
Subcommands: []*cli.Command{
|
|
||||||
appSecretGenerateCommand,
|
var (
|
||||||
appSecretInsertCommand,
|
storeInPass bool
|
||||||
appSecretRmCommand,
|
insertFromFile bool
|
||||||
appSecretLsCommand,
|
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,66 +1,159 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"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/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/urfave/cli/v2"
|
dockerClient "github.com/docker/docker/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var appUndeployCommand = &cli.Command{
|
var AppUndeployCommand = &cobra.Command{
|
||||||
Name: "undeploy",
|
Use: "undeploy <domain> [flags]",
|
||||||
Aliases: []string{"u"},
|
Aliases: []string{"un"},
|
||||||
Usage: "Undeploy an app",
|
Short: "Undeploy an app",
|
||||||
Description: `
|
Long: `This does not destroy any application data.
|
||||||
This does not destroy any of the application data. However, you should remain
|
|
||||||
vigilant, as your swarm installation will consider any previously attached
|
However, you should remain vigilant, as your swarm installation will consider
|
||||||
volumes as eligiblef or pruning once undeployed.
|
any previously attached volumes as eligible for pruning once undeployed.
|
||||||
`,
|
|
||||||
Action: func(c *cli.Context) error {
|
Passing "--prune/-p" does not remove those volumes.`,
|
||||||
app := internal.ValidateApp(c)
|
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()
|
stackName := app.StackName()
|
||||||
|
|
||||||
|
if err := app.Recipe.EnsureExists(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDeployed {
|
if !deployMeta.IsDeployed {
|
||||||
logrus.Fatalf("'%s' is not deployed?", stackName)
|
log.Fatalf("%s is not deployed?", app.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
|
if err := internal.DeployOverview(
|
||||||
logrus.Fatal(err)
|
app,
|
||||||
|
deployMeta.Version,
|
||||||
|
config.NO_DOMAIN_DEFAULT,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
|
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||||
if err := stack.RunRemove(c.Context, cl, rmOpts); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
opts := stack.Deploy{Composefiles: composeFiles, Namespace: stackName}
|
||||||
|
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,179 +1,461 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"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/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"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"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
dockerClient "github.com/docker/docker/client"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var appUpgradeCommand = &cli.Command{
|
var AppUpgradeCommand = &cobra.Command{
|
||||||
Name: "upgrade",
|
Use: "upgrade <domain> [version] [flags]",
|
||||||
Aliases: []string{"u"},
|
Aliases: []string{"up"},
|
||||||
Usage: "Upgrade an app",
|
Short: "Upgrade an app",
|
||||||
ArgsUsage: "<app>",
|
Long: `Upgrade an app.
|
||||||
Flags: []cli.Flag{
|
|
||||||
internal.ForceFlag,
|
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
|
||||||
internal.ChaosFlag,
|
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: `
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
This command supports upgrading an app. You can use it to choose and roll out a
|
var (
|
||||||
new upgrade to an existing app.
|
upgradeWarnMessages []string
|
||||||
|
chosenUpgrade string
|
||||||
|
availableUpgrades []string
|
||||||
|
upgradeReleaseNotes string
|
||||||
|
)
|
||||||
|
|
||||||
This command specifically supports changing the version of running apps, as
|
app := internal.ValidateApp(args)
|
||||||
opposed to "abra app deploy <app>" which will not change the version of a
|
|
||||||
deployed app.
|
|
||||||
|
|
||||||
You may pass "--force/-f" to upgrade to the same version again. This can be
|
if err := app.Recipe.Ensure(recipe.EnsureContext{
|
||||||
useful if the container runtime has gotten into a weird state.
|
Chaos: internal.Chaos,
|
||||||
|
Offline: internal.Offline,
|
||||||
This action could be destructive, please ensure you have a copy of your app
|
// Ignore the env version for now, to make sure we are at the latest commit.
|
||||||
data beforehand - see "abra app backup <app>" for more.
|
// This enables us to get release notes, that were added after a release.
|
||||||
|
IgnoreEnvVersion: true,
|
||||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
}); err != nil {
|
||||||
including unstaged changes and can be useful for live hacking and testing new
|
log.Fatal(err)
|
||||||
recipes.
|
}
|
||||||
`,
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
stackName := app.StackName()
|
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
cl, err := client.New(app.Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
deployMeta, err := ensureDeployed(cl, app)
|
||||||
|
|
||||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDeployed {
|
if err := lint.LintForErrors(app.Recipe); err != nil {
|
||||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
versions, err := app.Recipe.Tags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) == 0 && !internal.Chaos {
|
// NOTE(d1): we've no idea what the live deployment version is, so every
|
||||||
logrus.Fatalf("no versions available '%s' in recipe catalogue?", app.Type)
|
// possible upgrade can be shown. it's up to the user to make the choice
|
||||||
}
|
if deployMeta.Version == config.UNKNOWN_DEFAULT {
|
||||||
|
|
||||||
var availableUpgrades []string
|
|
||||||
if deployedVersion == "" {
|
|
||||||
deployedVersion = "unknown"
|
|
||||||
availableUpgrades = versions
|
availableUpgrades = versions
|
||||||
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if deployedVersion != "unknown" && !internal.Chaos {
|
if len(args) == 2 && args[1] != "" {
|
||||||
for _, version := range versions {
|
chosenUpgrade = args[1]
|
||||||
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
|
|
||||||
if err != nil {
|
if err := validateUpgradeVersionArg(chosenUpgrade, app, deployMeta); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
|
||||||
parsedVersion, err := tagcmp.Parse(version)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
if parsedVersion.IsGreaterThan(parsedDeployedVersion) {
|
|
||||||
availableUpgrades = append(availableUpgrades, version)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(availableUpgrades) == 0 && !internal.Force {
|
availableUpgrades = append(availableUpgrades, chosenUpgrade)
|
||||||
logrus.Fatal("no available upgrades, you're on latest")
|
|
||||||
availableUpgrades = versions
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var chosenUpgrade string
|
if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenUpgrade == "" {
|
||||||
if len(availableUpgrades) > 0 && !internal.Chaos {
|
upgradeAvailable, err := ensureUpgradesAvailable(app, versions, &availableUpgrades, deployMeta)
|
||||||
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 !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 err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !upgradeAvailable {
|
||||||
|
log.Info("no available upgrades")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
|
if internal.Force || internal.NoInput || chosenUpgrade != "" {
|
||||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
for k, v := range abraShEnv {
|
for k, v := range abraShEnv {
|
||||||
app.Env[k] = v
|
app.Env[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stackName := app.StackName()
|
||||||
deployOpts := stack.Deploy{
|
deployOpts := stack.Deploy{
|
||||||
Composefiles: composeFiles,
|
Composefiles: composeFiles,
|
||||||
Namespace: stackName,
|
Namespace: stackName,
|
||||||
Prune: false,
|
Prune: false,
|
||||||
ResolveImage: stack.ResolveImageAlways,
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade); err != nil {
|
appPkg.ExposeAllEnv(stackName, compose, app.Env)
|
||||||
logrus.Fatal(err)
|
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)
|
||||||
|
|
||||||
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
|
envVars, err := appPkg.CheckEnv(app)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
|
for _, envVar := range envVars {
|
||||||
|
if !envVar.Present {
|
||||||
|
upgradeWarnMessages = append(upgradeWarnMessages,
|
||||||
|
fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showReleaseNotes {
|
||||||
|
fmt.Print(upgradeReleaseNotes)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,104 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
|
||||||
"coopcloud.tech/abra/pkg/client"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
|
||||||
"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 == "" {
|
|
||||||
logrus.Fatalf("failed to determine version of deployed '%s'", app.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isDeployed {
|
|
||||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
versionsMeta := make(map[string]catalogue.ServiceMeta)
|
|
||||||
for _, recipeVersion := range recipeMeta.Versions {
|
|
||||||
if currentVersion, exists := recipeVersion[deployedVersion]; exists {
|
|
||||||
versionsMeta = currentVersion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(versionsMeta) == 0 {
|
|
||||||
logrus.Fatalf("PANIC: could not retrieve deployed version ('%s') from recipe catalogue?", deployedVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
tableCol := []string{"name", "image", "version", "tag", "digest"}
|
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
|
||||||
|
|
||||||
for serviceName, versionMeta := range versionsMeta {
|
|
||||||
table.Append([]string{serviceName, versionMeta.Image, deployedVersion, versionMeta.Tag, versionMeta.Digest})
|
|
||||||
}
|
|
||||||
|
|
||||||
table.Render()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,106 +1,202 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var appVolumeListCommand = &cli.Command{
|
var AppVolumeListCommand = &cobra.Command{
|
||||||
Name: "list",
|
Use: "list <domain> [flags]",
|
||||||
Usage: "List volumes associated with an app",
|
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Action: func(c *cli.Context) error {
|
Short: "List volumes associated with an app",
|
||||||
app := internal.ValidateApp(c)
|
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 {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
table := abraFormatter.CreateTable([]string{"driver", "volume name"})
|
filters, err := app.Filters(false, true)
|
||||||
var volTable [][]string
|
if err != nil {
|
||||||
for _, volume := range volumeList {
|
log.Fatal(err)
|
||||||
volRow := []string{
|
}
|
||||||
volume.Driver,
|
|
||||||
volume.Name,
|
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)
|
log.Warnf("no volumes created for %s", app.Name)
|
||||||
table.Render()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var appVolumeRemoveCommand = &cli.Command{
|
var AppVolumeRemoveCommand = &cobra.Command{
|
||||||
Name: "remove",
|
Use: "remove <domain> [volume] [flags]",
|
||||||
Usage: "Remove volume(s) associated with an app",
|
Short: "Remove volume(s) associated with an app",
|
||||||
Aliases: []string{"rm"},
|
Long: `Remove volumes associated with an app.
|
||||||
Flags: []cli.Flag{
|
|
||||||
internal.ForceFlag,
|
|
||||||
},
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
app := internal.ValidateApp(c)
|
|
||||||
|
|
||||||
volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
|
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/-f" will select all volumes for removal. Be careful.`,
|
||||||
|
Example: ` # delete volumes interactively
|
||||||
|
abra app volume rm 1312.net
|
||||||
|
|
||||||
|
# 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 {
|
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)
|
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
|
var volumesToRemove []string
|
||||||
if !internal.Force {
|
if !internal.Force && !internal.NoInput {
|
||||||
volumesPrompt := &survey.MultiSelect{
|
volumesPrompt := &survey.MultiSelect{
|
||||||
Message: "which volumes do you want to remove?",
|
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: volumeNames,
|
Options: volumeNames,
|
||||||
Default: volumeNames,
|
Default: volumeNames,
|
||||||
}
|
}
|
||||||
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
|
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if internal.Force || internal.NoInput {
|
||||||
volumesToRemove = volumeNames
|
volumesToRemove = volumeNames
|
||||||
}
|
}
|
||||||
|
|
||||||
err = client.RemoveVolumes(c.Context, app.Server, volumesToRemove, internal.Force)
|
if len(volumesToRemove) > 0 {
|
||||||
if err != nil {
|
err := client.RemoveVolumes(cl, context.Background(), volumesToRemove, internal.Force, 5)
|
||||||
logrus.Fatal(err)
|
if err != nil {
|
||||||
}
|
log.Fatalf("removing volumes failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
logrus.Info("volumes removed successfully")
|
log.Infof("%d volumes removed successfully", len(volumesToRemove))
|
||||||
|
} else {
|
||||||
return nil
|
log.Info("no volumes removed")
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
appNames, err := config.GetAppNames()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, a := range appNames {
|
|
||||||
fmt.Println(a)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var appVolumeCommand = &cli.Command{
|
var AppVolumeCommand = &cobra.Command{
|
||||||
Name: "volume",
|
Use: "volume [cmd] [args] [flags]",
|
||||||
Aliases: []string{"vl"},
|
Aliases: []string{"vl"},
|
||||||
Usage: "Manage app volumes",
|
Short: "Manage app volumes",
|
||||||
ArgsUsage: "<command>",
|
}
|
||||||
Subcommands: []*cli.Command{
|
|
||||||
appVolumeListCommand,
|
func init() {
|
||||||
appVolumeRemoveCommand,
|
AppVolumeRemoveCommand.Flags().BoolVarP(
|
||||||
},
|
&internal.Force,
|
||||||
|
"force",
|
||||||
|
"f",
|
||||||
|
false,
|
||||||
|
"perform action without further prompt",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// downloadFile downloads a file brah
|
|
||||||
func downloadFile(filepath string, url string) (err error) {
|
|
||||||
out, err := os.Create(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("bad status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoCompleteCommand helps people set up auto-complete in their shells
|
|
||||||
var AutoCompleteCommand = &cli.Command{
|
|
||||||
Name: "autocomplete",
|
|
||||||
Usage: "Help set up shell autocompletion",
|
|
||||||
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:
|
|
||||||
|
|
||||||
fish
|
|
||||||
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,
|
|
||||||
"fish": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := supportedShells[shellType]; !ok {
|
|
||||||
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if shellType == "fish" {
|
|
||||||
shellType = "zsh" // handled the same on the autocompletion side
|
|
||||||
}
|
|
||||||
|
|
||||||
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
|
|
||||||
if err := os.Mkdir(autocompletionDir, 0755); err != nil {
|
|
||||||
if !os.IsExist(err) {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Debugf("'%s' already created, moving on...", 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 := downloadFile(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
|
|
||||||
`, 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
|
|
||||||
`, autocompletionFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,17 +1,275 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/urfave/cli/v2"
|
"encoding/json"
|
||||||
|
"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/log"
|
||||||
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
|
var CatalogueGenerateCommand = &cobra.Command{
|
||||||
var CatalogueCommand = &cli.Command{
|
Use: "generate [recipe] [flags]",
|
||||||
Name: "catalogue",
|
Aliases: []string{"g"},
|
||||||
Usage: "Manage the recipe catalogue (for maintainers)",
|
Short: "Generate the recipe catalogue",
|
||||||
Aliases: []string{"c"},
|
Long: `Generate a new copy of the recipe catalogue.
|
||||||
ArgsUsage: "<recipe>",
|
|
||||||
Description: "This command helps recipe packagers interact with the recipe catalogue",
|
N.B. this command **will** wipe local unstaged changes from your local recipes
|
||||||
Subcommands: []*cli.Command{
|
if present. "--chaos/-C" on this command refers to the catalogue repository
|
||||||
catalogueGenerateCommand,
|
("$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.
|
||||||
|
|
||||||
|
It is quite easy to get rate limited by Docker Hub when running this command.
|
||||||
|
If you have a Hub account you can "docker login" and Abra will automatically
|
||||||
|
use those details.
|
||||||
|
|
||||||
|
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.`,
|
||||||
|
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(args, cmd.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := catalogue.EnsureCatalogue(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !internal.Chaos {
|
||||||
|
if err := catalogue.EnsureIsClean(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repos, err := recipe.ReadReposMetadata(internal.Debug)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
barLength := len(repos)
|
||||||
|
if recipeName != "" {
|
||||||
|
barLength = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "collecting catalogue metadata")
|
||||||
|
for _, recipeMeta := range repos {
|
||||||
|
if recipeName != "" && recipeName != recipeMeta.Name {
|
||||||
|
if !internal.Debug {
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
features, category, warnMsgs, err := recipe.GetRecipeFeaturesAndCategory(r)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, err.Error())
|
||||||
|
}
|
||||||
|
if len(warnMsgs) > 0 {
|
||||||
|
warnings = append(warnings, warnMsgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
catl[recipeMeta.Name] = recipe.RecipeMeta{
|
||||||
|
Name: recipeMeta.Name,
|
||||||
|
Repository: recipeMeta.CloneURL,
|
||||||
|
SSHURL: recipeMeta.SSHURL,
|
||||||
|
Icon: recipeMeta.AvatarURL,
|
||||||
|
DefaultBranch: recipeMeta.DefaultBranch,
|
||||||
|
Description: recipeMeta.Description,
|
||||||
|
Website: recipeMeta.Website,
|
||||||
|
Versions: versions,
|
||||||
|
Category: category,
|
||||||
|
Features: features,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipeName == "" {
|
||||||
|
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
catlFS, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
catlFS[recipeName] = catl[recipeName]
|
||||||
|
|
||||||
|
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("generated recipe catalogue: %s", config.RECIPES_JSON)
|
||||||
|
|
||||||
|
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
|
||||||
|
if publishChanges {
|
||||||
|
|
||||||
|
isClean, err := gitPkg.IsClean(cataloguePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isClean {
|
||||||
|
if !internal.Dry {
|
||||||
|
log.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "chore: publish new catalogue release changes"
|
||||||
|
if err := gitPkg.Commit(cataloguePath, msg, internal.Dry); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainOpen(cataloguePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainOpen(cataloguePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
head, err := repo.Head()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log.Info("dry run: no changes published")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
|
||||||
|
var CatalogueCommand = &cobra.Command{
|
||||||
|
Use: "catalogue [cmd] [args] [flags]",
|
||||||
|
Short: "Manage the recipe catalogue",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
publishChanges bool
|
||||||
|
skipUpdates bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||||
|
&publishChanges,
|
||||||
|
"publish",
|
||||||
|
"p",
|
||||||
|
false,
|
||||||
|
"publish changes to git.coopcloud.tech",
|
||||||
|
)
|
||||||
|
|
||||||
|
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||||
|
&internal.Dry,
|
||||||
|
"dry-run",
|
||||||
|
"r",
|
||||||
|
false,
|
||||||
|
"report changes that would be made",
|
||||||
|
)
|
||||||
|
|
||||||
|
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||||
|
&skipUpdates,
|
||||||
|
"skip-updates",
|
||||||
|
"s",
|
||||||
|
false,
|
||||||
|
"skip updating recipe repositories",
|
||||||
|
)
|
||||||
|
|
||||||
|
CatalogueGenerateCommand.Flags().BoolVarP(
|
||||||
|
&internal.Chaos,
|
||||||
|
"chaos",
|
||||||
|
"C",
|
||||||
|
false,
|
||||||
|
"ignore uncommitted recipes changes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,261 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
|
||||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
|
||||||
"coopcloud.tech/abra/pkg/limit"
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CatalogueSkipList is all the repos that are not recipes.
|
|
||||||
var CatalogueSkipList = map[string]bool{
|
|
||||||
"abra": true,
|
|
||||||
"abra-bash": true,
|
|
||||||
"abra-apps": true,
|
|
||||||
"abra-aur": 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,
|
|
||||||
"coopcloud.tech": true,
|
|
||||||
"coturn": true,
|
|
||||||
"docker-cp-deploy": true,
|
|
||||||
"docker-dind-bats-kcov": true,
|
|
||||||
"docs.coopcloud.tech": true,
|
|
||||||
"example": true,
|
|
||||||
"gardening": true,
|
|
||||||
"go-abra": true,
|
|
||||||
"organising": true,
|
|
||||||
"pyabra": true,
|
|
||||||
"radicle-seed-node": true,
|
|
||||||
"stack-ssh-deploy": true,
|
|
||||||
"swarm-cronjob": true,
|
|
||||||
"tagcmp": true,
|
|
||||||
"tyop": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var commit bool
|
|
||||||
var commitFlag = &cli.BoolFlag{
|
|
||||||
Name: "commit",
|
|
||||||
Usage: "Commits new generated catalogue changes",
|
|
||||||
Value: false,
|
|
||||||
Aliases: []string{"c"},
|
|
||||||
Destination: &commit,
|
|
||||||
}
|
|
||||||
|
|
||||||
var catalogueGenerateCommand = &cli.Command{
|
|
||||||
Name: "generate",
|
|
||||||
Aliases: []string{"g"},
|
|
||||||
Usage: "Generate a new copy of the catalogue",
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
internal.PushFlag,
|
|
||||||
commitFlag,
|
|
||||||
internal.CommitMessageFlag,
|
|
||||||
},
|
|
||||||
Description: `
|
|
||||||
This command generates a new copy of the recipe catalogue which can be found on:
|
|
||||||
|
|
||||||
https://recipes.coopcloud.tech
|
|
||||||
|
|
||||||
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
|
|
||||||
listing, parses README and tags to produce recipe metadata and produces a
|
|
||||||
apps.json file which is placed in your ~/.abra/catalogue/recipes.json.
|
|
||||||
|
|
||||||
It is possible to generate new metadata for a single recipe by passing
|
|
||||||
<recipe>. The existing local catalogue will be updated, not overwritten.
|
|
||||||
|
|
||||||
A new catalogue copy can be published to the recipes repository by passing the
|
|
||||||
"--commit" and "--push" flags. The recipes repository is available here:
|
|
||||||
|
|
||||||
https://git.coopcloud.tech/coop-cloud/recipes
|
|
||||||
|
|
||||||
`,
|
|
||||||
ArgsUsage: "[<recipe>]",
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
recipeName := c.Args().First()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
repos, err := catalogue.ReadReposMetadata()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("ensuring '%v' recipe(s) are locally present and up-to-date", len(repos))
|
|
||||||
|
|
||||||
cloneLimiter := limit.New(10)
|
|
||||||
retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes...")
|
|
||||||
ch := make(chan string, len(repos))
|
|
||||||
for _, repoMeta := range repos {
|
|
||||||
go func(rm catalogue.RepoMeta) {
|
|
||||||
cloneLimiter.Begin()
|
|
||||||
defer cloneLimiter.End()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeDir := path.Join(config.ABRA_DIR, "apps", rm.Name)
|
|
||||||
|
|
||||||
if err := gitPkg.Clone(recipeDir, rm.SSHURL); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := gitPkg.EnsureUpToDate(recipeDir); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ch <- rm.Name
|
|
||||||
retrieveBar.Add(1)
|
|
||||||
}(repoMeta)
|
|
||||||
}
|
|
||||||
|
|
||||||
for range repos {
|
|
||||||
<-ch // wait for everything
|
|
||||||
}
|
|
||||||
|
|
||||||
catl := make(catalogue.RecipeCatalogue)
|
|
||||||
catlBar := formatter.CreateProgressbar(len(repos), "generating catalogue...")
|
|
||||||
for _, recipeMeta := range repos {
|
|
||||||
if recipeName != "" && recipeName != recipeMeta.Name {
|
|
||||||
catlBar.Add(1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := CatalogueSkipList[recipeMeta.Name]; exists {
|
|
||||||
catlBar.Add(1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
versions, err := catalogue.GetRecipeVersions(recipeMeta.Name)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catl[recipeMeta.Name] = catalogue.RecipeMeta{
|
|
||||||
Name: recipeMeta.Name,
|
|
||||||
Repository: recipeMeta.CloneURL,
|
|
||||||
Icon: recipeMeta.AvatarURL,
|
|
||||||
DefaultBranch: recipeMeta.DefaultBranch,
|
|
||||||
Description: recipeMeta.Description,
|
|
||||||
Website: recipeMeta.Website,
|
|
||||||
Versions: versions,
|
|
||||||
// Category: ..., // FIXME: parse & load
|
|
||||||
// Features: ..., // FIXME: parse & load
|
|
||||||
}
|
|
||||||
catlBar.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
recipesJSON, err := json.MarshalIndent(catl, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(config.APPS_JSON); err != nil && os.IsNotExist(err) {
|
|
||||||
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if recipeName != "" {
|
|
||||||
catlFS, err := catalogue.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
catlFS[recipeName] = catl[recipeName]
|
|
||||||
|
|
||||||
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := ioutil.WriteFile(config.APPS_JSON, updatedRecipesJSON, 0644); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cataloguePath := path.Join(config.ABRA_DIR, "catalogue", "recipes.json")
|
|
||||||
logrus.Infof("generated new recipe catalogue in %s", cataloguePath)
|
|
||||||
|
|
||||||
if commit {
|
|
||||||
repoPath := path.Join(config.ABRA_DIR, "catalogue")
|
|
||||||
commitRepo, err := git.PlainOpen(repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
commitWorktree, err := commitRepo.Worktree()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if internal.CommitMessage == "" {
|
|
||||||
prompt := &survey.Input{
|
|
||||||
Message: "commit message",
|
|
||||||
Default: "chore: publish new catalogue changes",
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = commitWorktree.AddGlob("**.json")
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Debug("staged **.json for commit")
|
|
||||||
|
|
||||||
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Info("changes commited")
|
|
||||||
|
|
||||||
if err := commitRepo.Push(&git.PushOptions{}); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Info("changes pushed")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Warn(err)
|
|
||||||
}
|
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for name := range catl {
|
|
||||||
fmt.Println(name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
122
cli/cli.go
122
cli/cli.go
@ -1,122 +0,0 @@
|
|||||||
// Package cli provides the interface for the command-line.
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"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"
|
|
||||||
logrusStack "github.com/Gurpartap/logrus-stack"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verbose stores the variable from VerboseFlag.
|
|
||||||
var Verbose bool
|
|
||||||
|
|
||||||
// VerboseFlag turns on/off verbose logging down to the INFO level.
|
|
||||||
var VerboseFlag = &cli.BoolFlag{
|
|
||||||
Name: "verbose",
|
|
||||||
Aliases: []string{"V"},
|
|
||||||
Value: false,
|
|
||||||
Destination: &Verbose,
|
|
||||||
Usage: "Show INFO messages",
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
VerboseFlag,
|
|
||||||
DebugFlag,
|
|
||||||
internal.NoInputFlag,
|
|
||||||
},
|
|
||||||
Authors: []*cli.Author{
|
|
||||||
{Name: "3wordchant"},
|
|
||||||
{Name: "decentral1se"},
|
|
||||||
{Name: "knoflook"},
|
|
||||||
{Name: "roxxers"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
app.EnableBashCompletion = true
|
|
||||||
|
|
||||||
app.Before = func(c *cli.Context) error {
|
|
||||||
if 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.ABRA_DIR, "servers"),
|
|
||||||
path.Join(config.ABRA_DIR, "apps"),
|
|
||||||
path.Join(config.ABRA_DIR, "vendor"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
if err := os.Mkdir(path, 0755); err != nil {
|
|
||||||
if !os.IsExist(err) {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Debugf("'%s' already created, moving on...", path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logrus.Debugf("'%s' is missing, creating...", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package formatter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/docker/cli/cli/command/formatter"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/olekukonko/tablewriter"
|
|
||||||
"github.com/schollz/progressbar/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ShortenID(str string) string {
|
|
||||||
return str[:12]
|
|
||||||
}
|
|
||||||
|
|
||||||
func Truncate(str string) string {
|
|
||||||
return fmt.Sprintf(`"%s"`, formatter.Ellipsis(str, 19))
|
|
||||||
}
|
|
||||||
|
|
||||||
func SmallSHA(hash string) string {
|
|
||||||
return hash[:8]
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSha remove image sha from a string that are added in some docker outputs
|
|
||||||
func RemoveSha(str string) string {
|
|
||||||
return strings.Split(str, "@")[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
|
|
||||||
func HumanDuration(timestamp int64) string {
|
|
||||||
date := time.Unix(timestamp, 0)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
return units.HumanDuration(now.Sub(date)) + " ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTable prepares a table layout for output.
|
|
||||||
func CreateTable(columns []string) *tablewriter.Table {
|
|
||||||
table := tablewriter.NewWriter(os.Stdout)
|
|
||||||
table.SetHeader(columns)
|
|
||||||
return table
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProgressbar generates a progress bar
|
|
||||||
func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
|
|
||||||
return progressbar.NewOptions(
|
|
||||||
length,
|
|
||||||
progressbar.OptionClearOnFinish(),
|
|
||||||
progressbar.OptionSetPredictTime(false),
|
|
||||||
progressbar.OptionShowCount(),
|
|
||||||
progressbar.OptionFullWidth(),
|
|
||||||
progressbar.OptionSetDescription(title),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"os/exec"
|
"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.
|
// RunCmd runs a shell command and streams stdout/stderr in real-time.
|
||||||
func RunCmd(cmd *exec.Cmd) error {
|
func RunCmd(cmd *exec.Cmd) error {
|
||||||
r, err := cmd.StdoutPipe()
|
r, err := cmd.StdoutPipe()
|
||||||
|
|||||||
@ -1,261 +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{"S"},
|
|
||||||
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"},
|
|
||||||
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 int
|
|
||||||
|
|
||||||
var DNSTTLFlag = &cli.IntFlag{
|
|
||||||
Name: "ttl",
|
|
||||||
Value: 86400,
|
|
||||||
Aliases: []string{"T"},
|
|
||||||
Usage: "Domain name TTL value)",
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
@ -1,127 +1,58 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
|
||||||
"coopcloud.tech/abra/pkg/client"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/urfave/cli/v2"
|
dockerClient "github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeployAction is the main command-line action for this package
|
var borderStyle = lipgloss.NewStyle().
|
||||||
func DeployAction(c *cli.Context) error {
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
app := ValidateApp(c)
|
Padding(0, 1, 0, 1).
|
||||||
stackName := app.StackName()
|
MaxWidth(79).
|
||||||
|
BorderForeground(lipgloss.Color("63"))
|
||||||
|
|
||||||
cl, err := client.New(app.Server)
|
var headerStyle = lipgloss.NewStyle().
|
||||||
if err != nil {
|
Underline(true).
|
||||||
logrus.Fatal(err)
|
Bold(true).
|
||||||
}
|
PaddingBottom(1)
|
||||||
|
|
||||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
var leftStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
var rightStyle = lipgloss.NewStyle()
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDeployed {
|
// horizontal is a JoinHorizontal helper function.
|
||||||
if Force {
|
func horizontal(left, mid, right string) string {
|
||||||
logrus.Warnf("'%s' already deployed but continuing (--force)", stackName)
|
return lipgloss.JoinHorizontal(lipgloss.Left, left, mid, right)
|
||||||
} else if Chaos {
|
}
|
||||||
logrus.Warnf("'%s' already deployed but continuing (--chaos)", stackName)
|
|
||||||
} else {
|
|
||||||
logrus.Fatalf("'%s' is already deployed", stackName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
version := deployedVersion
|
func formatComposeFiles(composeFiles string) string {
|
||||||
if version == "" && !Chaos {
|
return strings.ReplaceAll(composeFiles, ":", "\n")
|
||||||
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
|
||||||
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 {
|
|
||||||
version = "latest commit"
|
|
||||||
logrus.Warn("no versions detected, using latest commit")
|
|
||||||
if err := recipe.EnsureLatest(app.Type); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if version == "" && !Chaos {
|
|
||||||
logrus.Debugf("choosing '%s' as version to deploy", version)
|
|
||||||
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.APPS_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: 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 err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeployOverview shows a deployment overview
|
// DeployOverview shows a deployment overview
|
||||||
func DeployOverview(app config.App, version, message string) error {
|
func DeployOverview(
|
||||||
tableCol := []string{"server", "compose", "domain", "stack", "version"}
|
app appPkg.App,
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
deployedVersion string,
|
||||||
|
toDeployVersion string,
|
||||||
|
releaseNotes string,
|
||||||
|
warnMessages []string,
|
||||||
|
) error {
|
||||||
deployConfig := "compose.yml"
|
deployConfig := "compose.yml"
|
||||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
deployConfig = formatComposeFiles(composeFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := app.Server
|
server := app.Server
|
||||||
@ -129,63 +60,213 @@ func DeployOverview(app config.App, version, message string) error {
|
|||||||
server = "local"
|
server = "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
|
domain := app.Domain
|
||||||
table.Render()
|
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 {
|
if NoInput {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
response := false
|
response := false
|
||||||
prompt := &survey.Confirm{
|
prompt := &survey.Confirm{Message: "proceed?"}
|
||||||
Message: message,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := survey.AskOne(prompt, &response); err != nil {
|
if err := survey.AskOne(prompt, &response); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !response {
|
if !response {
|
||||||
logrus.Fatal("exiting as requested")
|
log.Fatal("deployment cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVersionOverview shows an upgrade or downgrade overview
|
func getDeployType(currentVersion, newVersion string) string {
|
||||||
func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
|
if newVersion == config.NO_DOMAIN_DEFAULT {
|
||||||
tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
|
return "UNDEPLOY"
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
|
||||||
|
|
||||||
deployConfig := "compose.yml"
|
|
||||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
|
||||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
|
||||||
}
|
}
|
||||||
|
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
|
server := app.Server
|
||||||
if app.Server == "default" {
|
if app.Server == "default" {
|
||||||
server = "local"
|
server = "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
|
domain := app.Domain
|
||||||
table.Render()
|
if domain == "" {
|
||||||
|
domain = config.NO_DOMAIN_DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if NoInput {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
response := false
|
if Dry {
|
||||||
prompt := &survey.Confirm{
|
return fmt.Errorf("dry run")
|
||||||
Message: "continue with deployment?",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response := false
|
||||||
|
prompt := &survey.Confirm{Message: "proceed?"}
|
||||||
if err := survey.AskOne(prompt, &response); err != nil {
|
if err := survey.AskOne(prompt, &response); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !response {
|
if !response {
|
||||||
logrus.Fatal("exiting as requested")
|
return errors.New("cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingServiceName := false
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
if serviceName == targetServiceName {
|
||||||
|
matchingServiceName = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matchingServiceName {
|
||||||
|
return fmt.Errorf("no service %s for %s?", targetServiceName, app.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("running command %s %s within the context of %s_%s", cmdName, parsedCmdArgs, app.StackName(), targetServiceName)
|
||||||
|
|
||||||
|
requestTTY := true
|
||||||
|
if err := RunCmdRemote(
|
||||||
|
cl,
|
||||||
|
app,
|
||||||
|
requestTTY,
|
||||||
|
app.Recipe.AbraShPath, targetServiceName, cmdName, parsedCmdArgs, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(tagcmp.ByTagDesc(tags))
|
||||||
|
|
||||||
|
var desc []string
|
||||||
|
for _, t := range tags {
|
||||||
|
desc = append(desc, t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,38 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Testing functions that call os.Exit
|
|
||||||
// https://stackoverflow.com/questions/26225513/how-to-test-os-exit-scenarios-in-go
|
|
||||||
// https://talks.golang.org/2014/testing.slide#23
|
|
||||||
|
|
||||||
var testapp = &cli.App{
|
|
||||||
Name: "abra",
|
|
||||||
Usage: `The Co-op Cloud command-line utility belt 🎩🐇`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// not testing output as that changes. just if it exits with code 1
|
|
||||||
// does not work because of some weird errors on cli's part. Its a hard lib to test effectively.
|
|
||||||
// func TestShowSubcommandHelpAndError(t *testing.T) {
|
|
||||||
// if os.Getenv("HelpAndError") == "1" {
|
|
||||||
// ShowSubcommandHelpAndError(cli.NewContext(testapp, nil, nil), errors.New("Test error"))
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// cmd := exec.Command(os.Args[0], "-test.run=TestShowSubcommandHelpAndError")
|
|
||||||
// cmd.Env = append(os.Environ(), "HelpAndError=1")
|
|
||||||
// var out bytes.Buffer
|
|
||||||
// cmd.Stderr = &out
|
|
||||||
// err := cmd.Run()
|
|
||||||
// println(out.String())
|
|
||||||
// if !strings.Contains(out.String(), "Test error") {
|
|
||||||
|
|
||||||
// t.Fatalf("expected command to show the error causing the exit, did not get correct stdout output")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if e, ok := err.(*exec.ExitError); ok && !e.Success() {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// t.Fatalf("process ran with err %v, want exit status 1", err)
|
|
||||||
// }
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
|
||||||
"coopcloud.tech/abra/pkg/secret"
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AppSecrets map[string]string
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: config.SanitiseAppName(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 := config.EnsureAbraDirExists(); 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, recipe.Name); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if Secrets {
|
|
||||||
secrets, err := createSecrets(sanitisedAppName)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secretCols := []string{"Name", "Value"}
|
|
||||||
secretTable := abraFormatter.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 := abraFormatter.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,104 +2,55 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/distribution/reference"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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{"p", "z"},
|
|
||||||
Destination: &Patch,
|
|
||||||
}
|
|
||||||
|
|
||||||
var Dry bool
|
|
||||||
var DryFlag = &cli.BoolFlag{
|
|
||||||
Name: "dry-run",
|
|
||||||
Usage: "No changes are made, only reports changes that would be made",
|
|
||||||
Value: false,
|
|
||||||
Aliases: []string{"d"},
|
|
||||||
Destination: &Dry,
|
|
||||||
}
|
|
||||||
|
|
||||||
var Push bool
|
|
||||||
var PushFlag = &cli.BoolFlag{
|
|
||||||
Name: "push",
|
|
||||||
Usage: "Git push changes",
|
|
||||||
Value: false,
|
|
||||||
Aliases: []string{"P"},
|
|
||||||
Destination: &Push,
|
|
||||||
}
|
|
||||||
|
|
||||||
var CommitMessage string
|
|
||||||
var CommitMessageFlag = &cli.StringFlag{
|
|
||||||
Name: "commit-message",
|
|
||||||
Usage: "Commit message (implies --commit)",
|
|
||||||
Aliases: []string{"cm"},
|
|
||||||
Destination: &CommitMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
var Commit bool
|
|
||||||
var CommitFlag = &cli.BoolFlag{
|
|
||||||
Name: "commit",
|
|
||||||
Usage: "Commits compose.**yml file changes to recipe repository",
|
|
||||||
Value: false,
|
|
||||||
Aliases: []string{"c"},
|
|
||||||
Destination: &Commit,
|
|
||||||
}
|
|
||||||
|
|
||||||
var TagMessage string
|
|
||||||
var TagMessageFlag = &cli.StringFlag{
|
|
||||||
Name: "tag-comment",
|
|
||||||
Usage: "Description for release tag",
|
|
||||||
Aliases: []string{"t", "tm"},
|
|
||||||
Destination: &TagMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
// PromptBumpType prompts for version bump type
|
// PromptBumpType prompts for version bump type
|
||||||
func PromptBumpType(tagString string) error {
|
func PromptBumpType(tagString, latestRelease string) error {
|
||||||
if (!Major && !Minor && !Patch) && tagString == "" {
|
if (!Major && !Minor && !Patch) && tagString == "" {
|
||||||
fmt.Printf(`
|
fmt.Printf(`
|
||||||
semver cheat sheet (more via semver.org):
|
You need to make a decision about what kind of an update this new recipe
|
||||||
major: new features/bug fixes, backwards incompatible
|
version is. If someone else performs this upgrade, do they have to do some
|
||||||
minor: new features/bug fixes, backwards compatible
|
migration work or take care of some breaking changes? This can be signaled in
|
||||||
patch: bug fixes, backwards compatible
|
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).
|
||||||
|
the upgrade won't work without some preparation work and others need
|
||||||
|
to take care when performing it. "it could go wrong".
|
||||||
|
|
||||||
|
minor: new features/bug fixes, backwards compatible (e.g. 0.1.0 -> 0.2.0).
|
||||||
|
the upgrade should Just Work and there are no breaking changes in
|
||||||
|
the app and the recipe config. "it should go fine".
|
||||||
|
|
||||||
|
patch: bug fixes, backwards compatible (e.g. 0.0.1 -> 0.0.2). this upgrade
|
||||||
|
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
|
var chosenBumpType string
|
||||||
prompt := &survey.Select{
|
prompt := &survey.Select{
|
||||||
Message: fmt.Sprintf("select recipe version increment type"),
|
Message: fmt.Sprintf("select recipe version increment type"),
|
||||||
Options: []string{"major", "minor", "patch"},
|
Options: []string{"major", "minor", "patch"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := survey.AskOne(prompt, &chosenBumpType); err != nil {
|
if err := survey.AskOne(prompt, &chosenBumpType); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
SetBumpType(chosenBumpType)
|
SetBumpType(chosenBumpType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +65,7 @@ func GetBumpType() string {
|
|||||||
} else if Patch {
|
} else if Patch {
|
||||||
bumpType = "patch"
|
bumpType = "patch"
|
||||||
} else {
|
} else {
|
||||||
logrus.Fatal("no version bump type specififed?")
|
log.Fatal("no version bump type specififed?")
|
||||||
}
|
}
|
||||||
|
|
||||||
return bumpType
|
return bumpType
|
||||||
@ -129,24 +80,35 @@ func SetBumpType(bumpType string) {
|
|||||||
} else if bumpType == "patch" {
|
} else if bumpType == "patch" {
|
||||||
Patch = true
|
Patch = true
|
||||||
} else {
|
} else {
|
||||||
logrus.Fatal("no version bump type specififed?")
|
log.Fatal("no version bump type specififed?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMainApp retrieves the main 'app' image name
|
// GetMainAppImage retrieves the main 'app' image name
|
||||||
func GetMainApp(recipe recipe.Recipe) string {
|
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
|
||||||
var app string
|
var path string
|
||||||
|
|
||||||
for _, service := range recipe.Config.Services {
|
config, err := recipe.GetComposeConfig(nil)
|
||||||
name := service.Name
|
if err != nil {
|
||||||
if name == "app" {
|
return "", err
|
||||||
app = strings.Split(service.Image, ":")[0]
|
}
|
||||||
|
for _, service := range config.Services {
|
||||||
|
if service.Name == "app" {
|
||||||
|
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
path = reference.Path(img)
|
||||||
|
path = formatter.StripTagMeta(path)
|
||||||
|
|
||||||
|
return path, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if app == "" {
|
if path == "" {
|
||||||
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
|
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 DNSName == "" {
|
|
||||||
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureZoneArgument ensures a zone argument is present.
|
|
||||||
func EnsureZoneArgument(c *cli.Context) (string, error) {
|
|
||||||
var zone string
|
|
||||||
if c.Args().First() == "" && !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
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 len(HetznerCloudSSHKeys.Value()) == 0 {
|
|
||||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud ssh keys?"))
|
|
||||||
}
|
|
||||||
if HetznerCloudLocation == "" {
|
|
||||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
|
|
||||||
}
|
|
||||||
if HetznerCloudAPIToken == "" {
|
|
||||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,111 +1,118 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/pkg/app"
|
"coopcloud.tech/abra/pkg/app"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"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.
|
// ValidateRecipe ensures the recipe arg is valid.
|
||||||
func ValidateRecipe(c *cli.Context) recipe.Recipe {
|
func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
|
||||||
recipeName := c.Args().First()
|
var recipeName string
|
||||||
|
if len(args) > 0 {
|
||||||
if recipeName == "" {
|
recipeName = args[0]
|
||||||
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recipe, err := recipe.Get(recipeName)
|
var recipes []string
|
||||||
|
|
||||||
|
catl, err := recipe.ReadRecipeCatalogue(Offline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debugf("validated '%s' as recipe argument", recipeName)
|
knownRecipes := make(map[string]bool)
|
||||||
|
for name := range catl {
|
||||||
|
knownRecipes[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
return recipe
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateRecipeWithPrompt ensures a recipe argument is present before
|
for recipeName := range knownRecipes {
|
||||||
// validating, asking for input if required.
|
recipes = append(recipes, recipeName)
|
||||||
func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
|
}
|
||||||
recipeName := c.Args().First()
|
|
||||||
|
|
||||||
if recipeName == "" && !NoInput {
|
if recipeName == "" && !NoInput {
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
var recipes []string
|
|
||||||
for name := range catl {
|
|
||||||
recipes = append(recipes, name)
|
|
||||||
}
|
|
||||||
prompt := &survey.Select{
|
prompt := &survey.Select{
|
||||||
Message: "Select recipe",
|
Message: "Select recipe",
|
||||||
Options: recipes,
|
Options: recipes,
|
||||||
}
|
}
|
||||||
if err := survey.AskOne(prompt, &recipeName); err != nil {
|
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 == "" {
|
if recipeName == "" {
|
||||||
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
log.Fatal("no recipe name provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
recipe, 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 {
|
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 recipe
|
return chosenRecipe
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateApp ensures the app name arg is valid.
|
// ValidateApp ensures the app name arg is valid.
|
||||||
func ValidateApp(c *cli.Context) config.App {
|
func ValidateApp(args []string) app.App {
|
||||||
appName := c.Args().First()
|
if len(args) == 0 {
|
||||||
|
log.Fatal("no app provided")
|
||||||
if AppName != "" {
|
|
||||||
appName = AppName
|
|
||||||
logrus.Debugf("programmatically setting app name to %s", appName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if appName == "" {
|
appName := args[0]
|
||||||
ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := app.Get(appName)
|
app, err := app.Get(appName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := recipe.EnsureExists(app.Type); err != nil {
|
log.Debugf("validated %s as app argument", appName)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("validated '%s' as app argument", appName)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateDomain ensures the domain name arg is valid.
|
// ValidateDomain ensures the domain name arg is valid.
|
||||||
func ValidateDomain(c *cli.Context) (string, error) {
|
func ValidateDomain(args []string) string {
|
||||||
domainName := c.Args().First()
|
var domainName string
|
||||||
|
if len(args) > 0 {
|
||||||
|
domainName = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
if domainName == "" && !NoInput {
|
if domainName == "" && !NoInput {
|
||||||
prompt := &survey.Input{
|
prompt := &survey.Input{
|
||||||
@ -113,40 +120,29 @@ func ValidateDomain(c *cli.Context) (string, error) {
|
|||||||
Default: "example.com",
|
Default: "example.com",
|
||||||
}
|
}
|
||||||
if err := survey.AskOne(prompt, &domainName); err != nil {
|
if err := survey.AskOne(prompt, &domainName); err != nil {
|
||||||
return domainName, err
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if domainName == "" {
|
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
|
return domainName
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateServer ensures the server name arg is valid.
|
// ValidateServer ensures the server name arg is valid.
|
||||||
func ValidateServer(c *cli.Context) (string, error) {
|
func ValidateServer(args []string) string {
|
||||||
serverName := c.Args().First()
|
var serverName string
|
||||||
|
if len(args) > 0 {
|
||||||
|
serverName = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
serverNames, err := config.ReadServerNames()
|
serverNames, err := config.ReadServerNames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverName, err
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverName == "" && !NoInput {
|
if serverName == "" && !NoInput {
|
||||||
@ -155,15 +151,26 @@ func ValidateServer(c *cli.Context) (string, error) {
|
|||||||
Options: serverNames,
|
Options: serverNames,
|
||||||
}
|
}
|
||||||
if err := survey.AskOne(prompt, &serverName); err != nil {
|
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 == "" {
|
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
|
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,113 +1,140 @@
|
|||||||
package recipe
|
package recipe
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/abra/pkg/lint"
|
||||||
"github.com/docker/distribution/reference"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeLintCommand = &cli.Command{
|
var RecipeLintCommand = &cobra.Command{
|
||||||
Name: "lint",
|
Use: "lint <recipe> [flags]",
|
||||||
Usage: "Lint a recipe",
|
Short: "Lint a recipe",
|
||||||
Aliases: []string{"l"},
|
Aliases: []string{"l"},
|
||||||
ArgsUsage: "<recipe>",
|
Args: cobra.MinimumNArgs(1),
|
||||||
Action: func(c *cli.Context) error {
|
ValidArgsFunction: func(
|
||||||
recipe := internal.ValidateRecipe(c)
|
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())
|
||||||
|
|
||||||
expectedVersion := false
|
if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||||
if recipe.Config.Version == "3.8" {
|
log.Fatal(err)
|
||||||
expectedVersion = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
envSampleProvided := false
|
headers := []string{
|
||||||
envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe.Name)
|
"ref",
|
||||||
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
|
"rule",
|
||||||
envSampleProvided = true
|
"severity",
|
||||||
} else if err != nil {
|
"satisfied",
|
||||||
logrus.Fatal(err)
|
"skipped",
|
||||||
|
"resolve",
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceNamedApp := false
|
table, err := formatter.CreateTable()
|
||||||
traefikEnabled := false
|
if err != nil {
|
||||||
healthChecksForAllServices := true
|
log.Fatal(err)
|
||||||
allImagesTagged := true
|
}
|
||||||
noUnstableTags := true
|
|
||||||
semverLikeTags := true
|
|
||||||
for _, service := range recipe.Config.Services {
|
|
||||||
if service.Name == "app" {
|
|
||||||
serviceNamedApp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for label := range service.Deploy.Labels {
|
table.Headers(headers...)
|
||||||
if label == "traefik.enable" {
|
|
||||||
if service.Deploy.Labels[label] == "true" {
|
hasError := false
|
||||||
traefikEnabled = true
|
var rows [][]string
|
||||||
|
var warnMessages []string
|
||||||
|
for level := range lint.LintRules {
|
||||||
|
for _, rule := range lint.LintRules[level] {
|
||||||
|
if onlyError && rule.Level != "error" {
|
||||||
|
log.Debugf("skipping %s, does not have level \"error\"", rule.Ref)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped := false
|
||||||
|
if rule.Skip(recipe) {
|
||||||
|
skipped = true
|
||||||
|
}
|
||||||
|
|
||||||
|
skippedOutput := "-"
|
||||||
|
if skipped {
|
||||||
|
skippedOutput = "✅"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
satisfiedOutput := "✅"
|
||||||
if err != nil {
|
if !satisfied {
|
||||||
logrus.Fatal(err)
|
satisfiedOutput = "❌"
|
||||||
}
|
if skipped {
|
||||||
if reference.IsNameOnly(img) {
|
satisfiedOutput = "-"
|
||||||
allImagesTagged = false
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tag string
|
row := []string{
|
||||||
switch img.(type) {
|
rule.Ref,
|
||||||
case reference.NamedTagged:
|
rule.Description,
|
||||||
tag = img.(reference.NamedTagged).Tag()
|
rule.Level,
|
||||||
case reference.Named:
|
satisfiedOutput,
|
||||||
noUnstableTags = false
|
skippedOutput,
|
||||||
}
|
rule.HowToResolve,
|
||||||
|
}
|
||||||
|
|
||||||
if tag == "latest" {
|
rows = append(rows, row)
|
||||||
noUnstableTags = false
|
table.Row(row...)
|
||||||
}
|
|
||||||
|
|
||||||
if !tagcmp.IsParsable(tag) {
|
|
||||||
semverLikeTags = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if service.HealthCheck == nil {
|
|
||||||
healthChecksForAllServices = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tableCol := []string{"rule", "satisfied"}
|
if len(rows) > 0 {
|
||||||
table := formatter.CreateTable(tableCol)
|
if err := formatter.PrintTable(table); err != nil {
|
||||||
table.Append([]string{"compose files have the expected version", strconv.FormatBool(expectedVersion)})
|
log.Fatal(err)
|
||||||
table.Append([]string{"environment configuration is provided", strconv.FormatBool(envSampleProvided)})
|
}
|
||||||
table.Append([]string{"recipe contains a service named 'app'", strconv.FormatBool(serviceNamedApp)})
|
|
||||||
table.Append([]string{"traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)})
|
|
||||||
table.Append([]string{"all services have a healthcheck enabled", strconv.FormatBool(healthChecksForAllServices)})
|
|
||||||
table.Append([]string{"all images are using a tag", strconv.FormatBool(allImagesTagged)})
|
|
||||||
table.Append([]string{"no usage of unstable 'latest' tags", strconv.FormatBool(noUnstableTags)})
|
|
||||||
table.Append([]string{"all tags are using a semver-like format", strconv.FormatBool(semverLikeTags)})
|
|
||||||
table.Render()
|
|
||||||
|
|
||||||
return nil
|
for _, warnMsg := range warnMessages {
|
||||||
},
|
log.Warn(warnMsg)
|
||||||
BashComplete: func(c *cli.Context) {
|
}
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
if hasError {
|
||||||
logrus.Warn(err)
|
log.Warnf("critical errors present in %s config", recipe.Name)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for name := range catl {
|
|
||||||
fmt.Println(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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -3,37 +3,107 @@ package recipe
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/urfave/cli/v2"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeListCommand = &cli.Command{
|
var RecipeListCommand = &cobra.Command{
|
||||||
Name: "list",
|
Use: "list",
|
||||||
Usage: "List available recipes",
|
Short: "List recipes",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Action: func(c *cli.Context) error {
|
Args: cobra.NoArgs,
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err.Error())
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
recipes := catl.Flatten()
|
recipes := catl.Flatten()
|
||||||
sort.Sort(catalogue.ByRecipeName(recipes))
|
sort.Sort(recipe.ByRecipeName(recipes))
|
||||||
|
|
||||||
tableCol := []string{"name", "category", "status"}
|
table, err := formatter.CreateTable()
|
||||||
table := formatter.CreateTable(tableCol)
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
for _, recipe := range recipes {
|
|
||||||
status := fmt.Sprintf("%v", recipe.Features.Status)
|
|
||||||
tableRow := []string{recipe.Name, recipe.Category, status}
|
|
||||||
table.Append(tableRow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table.Render()
|
headers := []string{
|
||||||
|
"name",
|
||||||
|
"category",
|
||||||
|
"status",
|
||||||
|
"healthcheck",
|
||||||
|
"backups",
|
||||||
|
"email",
|
||||||
|
"tests",
|
||||||
|
"SSO",
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
table.Headers(headers...)
|
||||||
|
|
||||||
|
var rows [][]string
|
||||||
|
for _, recipe := range recipes {
|
||||||
|
row := []string{
|
||||||
|
recipe.Name,
|
||||||
|
recipe.Category,
|
||||||
|
strconv.Itoa(recipe.Features.Status),
|
||||||
|
recipe.Features.Healthcheck,
|
||||||
|
recipe.Features.Backups,
|
||||||
|
recipe.Features.Email,
|
||||||
|
recipe.Features.Tests,
|
||||||
|
recipe.Features.SSO,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern != "" {
|
||||||
|
if strings.Contains(recipe.Name, pattern) {
|
||||||
|
table.Row(row...)
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table.Row(row...)
|
||||||
|
rows = append(rows, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,92 +1,127 @@
|
|||||||
package recipe
|
package recipe
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"coopcloud.tech/abra/pkg/git"
|
"coopcloud.tech/abra/pkg/git"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/urfave/cli/v2"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeNewCommand = &cli.Command{
|
// recipeMetadata is the recipe metadata for the README.md
|
||||||
Name: "new",
|
type recipeMetadata struct {
|
||||||
Usage: "Create a new recipe",
|
Name string
|
||||||
Aliases: []string{"n"},
|
Description string
|
||||||
ArgsUsage: "<recipe>",
|
Category string
|
||||||
Description: `
|
Status string
|
||||||
This command creates a new recipe.
|
Image string
|
||||||
|
Healthcheck string
|
||||||
|
Backups string
|
||||||
|
Email string
|
||||||
|
Tests string
|
||||||
|
SSO string
|
||||||
|
}
|
||||||
|
|
||||||
Abra uses our built-in example repository which is available here:
|
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]
|
||||||
|
|
||||||
https://git.coopcloud.tech/coop-cloud/example
|
r := recipe.Get(recipeName)
|
||||||
|
if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
|
||||||
Files within the example repository make use of the Golang templating system
|
log.Fatalf("%s recipe directory already exists?", r.Dir)
|
||||||
which Abra uses to inject values into the generated recipe folder (e.g. name of
|
|
||||||
recipe and domain in the sample environment config).
|
|
||||||
|
|
||||||
The new example repository is cloned to ~/.abra/apps/<recipe>.
|
|
||||||
`,
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
recipeName := c.Args().First()
|
|
||||||
|
|
||||||
if recipeName == "" {
|
|
||||||
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
||||||
}
|
|
||||||
|
|
||||||
directory := path.Join(config.APPS_DIR, recipeName)
|
|
||||||
if _, err := os.Stat(directory); !os.IsNotExist(err) {
|
|
||||||
logrus.Fatalf("'%s' recipe directory already exists?", directory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
|
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
|
||||||
if err := git.Clone(directory, url); err != nil {
|
if err := git.Clone(r.Dir, url); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gitRepo := path.Join(config.APPS_DIR, recipeName, ".git")
|
gitRepo := path.Join(r.Dir, ".git")
|
||||||
if err := os.RemoveAll(gitRepo); err != nil {
|
if err := os.RemoveAll(gitRepo); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
logrus.Debugf("removed git repo in '%s'", gitRepo)
|
log.Debugf("removed .git repo in %s", gitRepo)
|
||||||
|
|
||||||
toParse := []string{
|
meta := newRecipeMeta(recipeName)
|
||||||
path.Join(config.APPS_DIR, recipeName, "README.md"),
|
|
||||||
path.Join(config.APPS_DIR, recipeName, ".env.sample"),
|
|
||||||
path.Join(config.APPS_DIR, recipeName, ".drone.yml"),
|
|
||||||
}
|
|
||||||
for _, path := range toParse {
|
|
||||||
file, err := os.OpenFile(path, os.O_RDWR, 0755)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for _, path := range []string{r.ReadmePath, r.SampleEnvPath} {
|
||||||
tpl, err := template.ParseFiles(path)
|
tpl, err := template.ParseFiles(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: ask for description and probably other things so that the
|
var templated bytes.Buffer
|
||||||
// template repository is more "ready" to go than the current best-guess
|
if err := tpl.Execute(&templated, meta); err != nil {
|
||||||
// mode of templating
|
log.Fatal(err)
|
||||||
if err := tpl.Execute(file, struct {
|
}
|
||||||
Name string
|
|
||||||
Description string
|
if err := os.WriteFile(path, templated.Bytes(), 0o644); err != nil {
|
||||||
}{recipeName, "TODO"}); err != nil {
|
log.Fatal(err)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof(
|
if err := git.Init(r.Dir, true, gitName, gitEmail); err != nil {
|
||||||
"new recipe '%s' created in %s, happy hacking!\n",
|
log.Fatal(err)
|
||||||
recipeName, path.Join(config.APPS_DIR, recipeName),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
log.Infof("new recipe '%s' created: %s", recipeName, path.Join(r.Dir))
|
||||||
|
log.Info("happy hacking 🎉")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRecipeMeta creates a new recipeMetadata instance with defaults
|
||||||
|
func newRecipeMeta(recipeName string) recipeMetadata {
|
||||||
|
return recipeMetadata{
|
||||||
|
Name: recipeName,
|
||||||
|
Description: "> One line description of the recipe",
|
||||||
|
Category: "Apps",
|
||||||
|
Status: "0",
|
||||||
|
Image: fmt.Sprintf("[`%s`](https://hub.docker.com/r/%s), 4, upstream", recipeName, recipeName),
|
||||||
|
Healthcheck: "No",
|
||||||
|
Backups: "No",
|
||||||
|
Email: "No",
|
||||||
|
Tests: "No",
|
||||||
|
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,27 +1,19 @@
|
|||||||
package recipe
|
package recipe
|
||||||
|
|
||||||
import (
|
import "github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecipeCommand defines all recipe related sub-commands.
|
// RecipeCommand defines all recipe related sub-commands.
|
||||||
var RecipeCommand = &cli.Command{
|
var RecipeCommand = &cobra.Command{
|
||||||
Name: "recipe",
|
Use: "recipe [cmd] [args] [flags]",
|
||||||
Usage: "Manage recipes (for maintainers)",
|
Aliases: []string{"r"},
|
||||||
ArgsUsage: "<recipe>",
|
Short: "Manage recipes",
|
||||||
Aliases: []string{"r"},
|
Long: `A recipe is a blueprint for an app.
|
||||||
Description: `
|
|
||||||
A recipe is a blueprint for an app. It is a bunch of configuration files which
|
It is a bunch of config files which describe how to deploy and maintain an app.
|
||||||
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
|
Recipes are maintained by the Co-op Cloud community and you can use Abra to
|
||||||
Cloud community and you can use Abra to read them and create apps for you.
|
read them, deploy them and create apps for you.
|
||||||
`,
|
|
||||||
Subcommands: []*cli.Command{
|
Anyone who uses a recipe can become a maintainer. Maintainers typically make
|
||||||
recipeListCommand,
|
sure the recipe is in good working order and the config upgraded in a timely
|
||||||
recipeVersionCommand,
|
manner.`,
|
||||||
recipeReleaseCommand,
|
|
||||||
recipeNewCommand,
|
|
||||||
recipeUpgradeCommand,
|
|
||||||
recipeSyncCommand,
|
|
||||||
recipeLintCommand,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,306 +1,158 @@
|
|||||||
package recipe
|
package recipe
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
|
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/docker/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
"github.com/spf13/cobra"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeReleaseCommand = &cli.Command{
|
var RecipeReleaseCommand = &cobra.Command{
|
||||||
Name: "release",
|
Use: "release <recipe> [version] [flags]",
|
||||||
Usage: "Release a new recipe version",
|
Aliases: []string{"rl"},
|
||||||
Aliases: []string{"rl"},
|
Short: "Release a new recipe version",
|
||||||
ArgsUsage: "<recipe> [<version>]",
|
Long: `Create a new version of a recipe.
|
||||||
Description: `
|
|
||||||
This command is used to specify a new tag for a recipe. These tags are used to
|
|
||||||
identify different versions of the recipe and are published on the Co-op Cloud
|
|
||||||
recipe catalogue.
|
|
||||||
|
|
||||||
These tags take the following form:
|
These versions are then published on the Co-op Cloud recipe catalogue. These
|
||||||
|
versions take the following form:
|
||||||
|
|
||||||
a.b.c+x.y.z
|
a.b.c+x.y.z
|
||||||
|
|
||||||
Where the "a.b.c" part is maintained as a semantic version of the recipe by the
|
Where the "a.b.c" part is a semantic version determined by the maintainer. The
|
||||||
recipe maintainer. And the "x.y.z" part is the image tag of the recipe "app"
|
"x.y.z" part is the image tag of the recipe "app" service (the main container
|
||||||
service (the main container which contains the software to be used).
|
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 in order to maximise the chances that the nature of recipe
|
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
|
||||||
updates are properly communicated.
|
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.
|
||||||
|
|
||||||
Abra does its best to read the "a.b.c" version scheme and communicate what
|
Publish your new release to git.coopcloud.tech with "--publish/-p". This
|
||||||
action needs to be taken when performing different operations such as an update
|
requires that you have permission to git push to these repositories and have
|
||||||
or a rollback of an app.
|
your SSH keys configured on your account.`,
|
||||||
|
Args: cobra.RangeArgs(1, 2),
|
||||||
You may invoke this command in "wizard" mode and be prompted for input:
|
ValidArgsFunction: func(
|
||||||
|
cmd *cobra.Command,
|
||||||
abra recipe release gitea
|
args []string,
|
||||||
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
`,
|
switch l := len(args); l {
|
||||||
Flags: []cli.Flag{
|
case 0:
|
||||||
internal.DryFlag,
|
return autocomplete.RecipeNameComplete()
|
||||||
internal.MajorFlag,
|
case 1:
|
||||||
internal.MinorFlag,
|
return autocomplete.RecipeVersionComplete(args[0])
|
||||||
internal.PatchFlag,
|
default:
|
||||||
internal.PushFlag,
|
return nil, cobra.ShellCompDirectiveDefault
|
||||||
internal.CommitFlag,
|
}
|
||||||
internal.CommitMessageFlag,
|
|
||||||
internal.TagMessageFlag,
|
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
|
||||||
tagString := c.Args().Get(1)
|
|
||||||
mainApp := internal.GetMainApp(recipe)
|
|
||||||
|
|
||||||
imagesTmp, err := getImageVersions(recipe)
|
imagesTmp, err := getImageVersions(recipe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainApp, err := internal.GetMainAppImage(recipe)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
mainAppVersion := imagesTmp[mainApp]
|
mainAppVersion := imagesTmp[mainApp]
|
||||||
|
if mainAppVersion == "" {
|
||||||
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
|
log.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mainAppVersion == "" {
|
var tagString string
|
||||||
logrus.Fatalf("main 'app' service version for %s is empty?", recipe.Name)
|
if len(args) == 2 {
|
||||||
|
tagString = args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if tagString != "" {
|
if tagString != "" {
|
||||||
if _, err := tagcmp.Parse(tagString); err != nil {
|
if _, err := tagcmp.Parse(tagString); err != nil {
|
||||||
logrus.Fatal("invalid tag specified")
|
log.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!internal.Major && !internal.Minor && !internal.Patch) && tagString != "" {
|
|
||||||
logrus.Fatal("please specify <version> or bump type (--major/--minor/--patch)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (internal.Major || internal.Minor || internal.Patch) && 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")
|
||||||
}
|
|
||||||
|
|
||||||
// bumpType is used to decide what part of the tag should be incremented
|
|
||||||
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.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := internal.PromptBumpType(tagString); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if internal.TagMessage == "" {
|
|
||||||
prompt := &survey.Input{
|
|
||||||
Message: "tag message",
|
|
||||||
Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var createTagOptions git.CreateTagOptions
|
|
||||||
createTagOptions.Message = internal.TagMessage
|
|
||||||
|
|
||||||
if !internal.Commit {
|
|
||||||
prompt := &survey.Confirm{
|
|
||||||
Message: "git commit changes also?",
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !internal.Push {
|
|
||||||
prompt := &survey.Confirm{
|
|
||||||
Message: "git push changes also?",
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &internal.Push); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if internal.Commit || internal.CommitMessage != "" {
|
|
||||||
commitRepo, err := git.PlainOpen(directory)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
commitWorktree, err := commitRepo.Worktree()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if internal.CommitMessage == "" {
|
|
||||||
prompt := &survey.Input{
|
|
||||||
Message: "commit message",
|
|
||||||
Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
|
|
||||||
}
|
|
||||||
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = commitWorktree.AddGlob("compose.**yml")
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Debug("staged compose.**yml for commit")
|
|
||||||
|
|
||||||
if !internal.Dry {
|
|
||||||
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Info("changes commited")
|
|
||||||
} else {
|
|
||||||
logrus.Info("dry run only: NOT committing changes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, err := git.PlainOpen(directory)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
head, err := repo.Head()
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tagString != "" {
|
if tagString != "" {
|
||||||
tag, err := tagcmp.Parse(tagString)
|
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||||
if err != nil {
|
log.Fatal(err)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if tag.MissingMinor {
|
|
||||||
tag.Minor = "0"
|
|
||||||
tag.MissingMinor = false
|
|
||||||
}
|
|
||||||
if tag.MissingPatch {
|
|
||||||
tag.Patch = "0"
|
|
||||||
tag.MissingPatch = false
|
|
||||||
}
|
|
||||||
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
|
|
||||||
if internal.Dry {
|
|
||||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
|
||||||
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagString, hash))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.CreateTag(tagString, head.Hash(), &createTagOptions)
|
|
||||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
|
||||||
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
|
||||||
if internal.Push && !internal.Dry {
|
|
||||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
|
|
||||||
} else {
|
|
||||||
logrus.Info("dry run only: NOT pushing changes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the latest tag with its hash, name etc
|
tags, err := recipe.Tags()
|
||||||
var lastGitTag tagcmp.Tag
|
|
||||||
iter, err := repo.Tags()
|
|
||||||
if err != nil {
|
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 tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||||
|
var err error
|
||||||
|
tagString, err = getLabelVersion(recipe, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatal(err)
|
||||||
}
|
|
||||||
tagcmpTag, err := tagcmp.Parse(obj.Name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if (lastGitTag == tagcmp.Tag{}) {
|
|
||||||
lastGitTag = tagcmpTag
|
|
||||||
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
|
|
||||||
lastGitTag = tagcmpTag
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newTag := lastGitTag
|
|
||||||
var newtagString string
|
|
||||||
if bumpType > 0 {
|
|
||||||
if internal.Patch {
|
|
||||||
now, err := strconv.Atoi(newTag.Patch)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
newTag.Patch = strconv.Itoa(now + 1)
|
|
||||||
} else if internal.Minor {
|
|
||||||
now, err := strconv.Atoi(newTag.Minor)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
newTag.Patch = "0"
|
|
||||||
newTag.Minor = strconv.Itoa(now + 1)
|
|
||||||
} else if internal.Major {
|
|
||||||
now, err := strconv.Atoi(newTag.Major)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
newTag.Patch = "0"
|
|
||||||
newTag.Minor = "0"
|
|
||||||
newTag.Major = strconv.Itoa(now + 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newTag.Metadata = mainAppVersion
|
isClean, err := gitPkg.IsClean(recipe.Dir)
|
||||||
newtagString = newTag.String()
|
if err != nil {
|
||||||
if internal.Dry {
|
log.Fatal(err)
|
||||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
|
||||||
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newtagString, hash))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.CreateTag(newtagString, head.Hash(), &createTagOptions)
|
if !isClean {
|
||||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
|
||||||
logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash))
|
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
|
||||||
if internal.Push && !internal.Dry {
|
log.Fatal(err)
|
||||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
}
|
||||||
logrus.Fatal(err)
|
}
|
||||||
|
|
||||||
|
if len(tags) > 0 {
|
||||||
|
log.Warnf("previous git tags detected, assuming this is a new semver release")
|
||||||
|
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString))
|
|
||||||
} else {
|
} else {
|
||||||
logrus.Info("gry run only: NOT pushing changes")
|
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(recipe, tagString); err != nil {
|
||||||
|
log.Fatal(cleanUpErr)
|
||||||
|
}
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// getImageVersions retrieves image versions for a recipe
|
// getImageVersions retrieves image versions for a recipe
|
||||||
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
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 == "" {
|
if service.Image == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -311,24 +163,77 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := reference.Path(img)
|
path := reference.Path(img)
|
||||||
if strings.Contains(path, "library") {
|
|
||||||
path = strings.Split(path, "/")[1]
|
path = formatter.StripTagMeta(path)
|
||||||
}
|
|
||||||
|
|
||||||
var tag string
|
var tag string
|
||||||
switch img.(type) {
|
switch img.(type) {
|
||||||
case reference.NamedTagged:
|
case reference.NamedTagged:
|
||||||
tag = img.(reference.NamedTagged).Tag()
|
tag = img.(reference.NamedTagged).Tag()
|
||||||
case reference.Named:
|
case reference.Named:
|
||||||
logrus.Fatalf("%s service is missing image tag?", path)
|
if service.Name == "app" {
|
||||||
|
missingTag = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
services[path] = tag
|
services[path] = tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if missingTag {
|
||||||
|
return services, fmt.Errorf("app service is missing image tag?")
|
||||||
|
}
|
||||||
|
|
||||||
return services, nil
|
return services, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createReleaseFromTag creates a new release based on a supplied recipe version string
|
||||||
|
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
repo, err := git.PlainOpen(recipe.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tagcmp.Parse(tagString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag.MissingMinor {
|
||||||
|
tag.Minor = "0"
|
||||||
|
tag.MissingMinor = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag.MissingPatch {
|
||||||
|
tag.Patch = "0"
|
||||||
|
tag.MissingPatch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagString == "" {
|
||||||
|
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 {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tagRelease(tagString, repo); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pushRelease(recipe, tagString); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// btoi converts a boolean value into an integer
|
// btoi converts a boolean value into an integer
|
||||||
func btoi(b bool) int {
|
func btoi(b bool) int {
|
||||||
if b {
|
if b {
|
||||||
@ -337,3 +242,372 @@ func btoi(b bool) int {
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTagCreateOptions constructs git tag create options
|
||||||
|
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
|
||||||
|
msg := fmt.Sprintf("chore: publish %s release", tag)
|
||||||
|
return git.CreateTagOptions{Message: msg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("chore: publish %s release", tag)
|
||||||
|
if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagRelease(tagString string, repo *git.Repository) error {
|
||||||
|
if internal.Dry {
|
||||||
|
log.Debugf("dry run: no git tag created (%s)", tagString)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
head, err := repo.Head()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createTagOptions, err := getTagCreateOptions(tagString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := formatter.SmallSHA(head.Hash().String())
|
||||||
|
log.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushRelease(recipe recipe.Recipe, tagString string) error {
|
||||||
|
if internal.Dry {
|
||||||
|
log.Info("dry run: no changes published")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !publish && !internal.NoInput {
|
||||||
|
prompt := &survey.Confirm{
|
||||||
|
Message: "publish new release?",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := survey.AskOne(prompt, &publish); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if publish {
|
||||||
|
if err := recipe.Push(internal.Dry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
repo, err := git.PlainOpen(recipe.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
|
||||||
|
if bumpType != 0 {
|
||||||
|
if (bumpType & (bumpType - 1)) != 0 {
|
||||||
|
return fmt.Errorf("you can only use one of: --major, --minor, --patch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastGitTag tagcmp.Tag
|
||||||
|
for _, tag := range tags {
|
||||||
|
parsed, err := tagcmp.Parse(tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastGitTag == tagcmp.Tag{}) {
|
||||||
|
lastGitTag = parsed
|
||||||
|
} else if parsed.IsGreaterThan(lastGitTag) {
|
||||||
|
lastGitTag = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newTag := lastGitTag
|
||||||
|
if internal.Patch {
|
||||||
|
now, err := strconv.Atoi(newTag.Patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newTag.Patch = strconv.Itoa(now + 1)
|
||||||
|
} else if internal.Minor {
|
||||||
|
now, err := strconv.Atoi(newTag.Minor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newTag.Patch = "0"
|
||||||
|
newTag.Minor = strconv.Itoa(now + 1)
|
||||||
|
} else if internal.Major {
|
||||||
|
now, err := strconv.Atoi(newTag.Major)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newTag.Patch = "0"
|
||||||
|
newTag.Minor = "0"
|
||||||
|
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 {
|
||||||
|
log.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !internal.NoInput {
|
||||||
|
prompt := &survey.Confirm{
|
||||||
|
Message: fmt.Sprintf("current: %s, new: %s, correct?", lastGitTag, tagString),
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
if err := survey.AskOne(prompt, &ok); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
log.Fatal("exiting as requested")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addReleaseNotes(recipe, tagString); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commitRelease(recipe, tagString); err != nil {
|
||||||
|
log.Fatalf("failed to commit changes: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tagRelease(tagString, repo); err != nil {
|
||||||
|
log.Fatalf("failed to tag release: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pushRelease(recipe, tagString); err != nil {
|
||||||
|
log.Fatalf("failed to publish new release: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanUpTag removes a freshly created tag
|
||||||
|
func cleanUpTag(recipe recipe.Recipe, tag string) error {
|
||||||
|
repo, err := git.PlainOpen(recipe.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DeleteTag(tag); err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "not found") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("removed freshly created tag %s", tag)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
|
||||||
|
initTag, err := recipe.GetVersionLabelLocal()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if initTag == "" {
|
||||||
|
log.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warnf("discovered %s as currently synced recipe label", initTag)
|
||||||
|
|
||||||
|
if prompt && !internal.NoInput {
|
||||||
|
var response bool
|
||||||
|
prompt := &survey.Confirm{Message: fmt.Sprintf("use %s as the new version?", initTag)}
|
||||||
|
if err := survey.AskOne(prompt, &response); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response {
|
||||||
|
return "", fmt.Errorf("please fix your synced label for %s and re-run this command", recipe.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,110 +2,147 @@ package recipe
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"coopcloud.tech/tagcmp"
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeSyncCommand = &cli.Command{
|
var RecipeSyncCommand = &cobra.Command{
|
||||||
Name: "sync",
|
Use: "sync <recipe> [version] [flags]",
|
||||||
Usage: "Ensure recipe version labels are up-to-date",
|
Aliases: []string{"s"},
|
||||||
Aliases: []string{"s"},
|
Short: "Sync recipe version label",
|
||||||
ArgsUsage: "<recipe> [<version>]",
|
Long: `Generate labels for the main recipe service.
|
||||||
Flags: []cli.Flag{
|
|
||||||
internal.DryFlag,
|
By convention, the service named "app" using the following format:
|
||||||
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:
|
|
||||||
|
|
||||||
coop-cloud.${STACK_NAME}.version=<version>
|
coop-cloud.${STACK_NAME}.version=<version>
|
||||||
|
|
||||||
The <version> is determined by the recipe maintainer and is specified on the
|
Where [version] can be specifed on the command-line or Abra can attempt to
|
||||||
command-line. The <recipe> configuration will be updated on the local file
|
auto-generate it for you. The <recipe> configuration will be updated on the
|
||||||
system.
|
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())
|
||||||
|
|
||||||
You may invoke this command in "wizard" mode and be prompted for input:
|
mainApp, err := internal.GetMainAppImage(recipe)
|
||||||
|
if err != nil {
|
||||||
abra recipe sync gitea
|
log.Fatal(err)
|
||||||
|
}
|
||||||
`,
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
|
||||||
|
|
||||||
mainApp := internal.GetMainApp(recipe)
|
|
||||||
|
|
||||||
imagesTmp, err := getImageVersions(recipe)
|
imagesTmp, err := getImageVersions(recipe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainAppVersion := imagesTmp[mainApp]
|
mainAppVersion := imagesTmp[mainApp]
|
||||||
|
|
||||||
tags, err := recipe.Tags()
|
tags, err := recipe.Tags()
|
||||||
if err != nil {
|
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 == "" {
|
if len(tags) == 0 && nextTag == "" {
|
||||||
logrus.Warnf("no 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
|
||||||
|
semver convention (more on https://semver.org), here is a short cheatsheet
|
||||||
|
|
||||||
|
0.1.0: development release, still hacking. when you make a major upgrade
|
||||||
|
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
|
||||||
|
using the "x" part when things are stable.
|
||||||
|
|
||||||
|
1.0.0: public release, assumed to be working. you already have a stable
|
||||||
|
and reliable deployment of this app and feel relatively confident
|
||||||
|
about it.
|
||||||
|
|
||||||
|
If you want people to be able alpha test your current config for %s but don't
|
||||||
|
think it is quite reliable, go with 0.1.0 and people will know that things are
|
||||||
|
likely to change.
|
||||||
|
|
||||||
|
`, recipe.Name, recipe.Name))
|
||||||
var chosenVersion string
|
var chosenVersion string
|
||||||
edPrompt := &survey.Select{
|
edPrompt := &survey.Select{
|
||||||
Message: "which version do you want to begin with?",
|
Message: "which version do you want to begin with?",
|
||||||
Options: []string{"0.1.0", "1.0.0"},
|
Options: []string{"0.1.0", "1.0.0"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
|
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
|
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||||
if err := internal.PromptBumpType(""); err != nil {
|
latestRelease := tags[len(tags)-1]
|
||||||
logrus.Fatal(err)
|
if err := internal.PromptBumpType("", latestRelease); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nextTag == "" {
|
if nextTag == "" {
|
||||||
recipeDir := path.Join(config.APPS_DIR, recipe.Name)
|
repo, err := git.PlainOpen(recipe.Dir)
|
||||||
repo, err := git.PlainOpen(recipeDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastGitTag tagcmp.Tag
|
var lastGitTag tagcmp.Tag
|
||||||
iter, err := repo.Tags()
|
iter, err := repo.Tags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
||||||
obj, err := repo.TagObject(ref.Hash())
|
obj, err := repo.TagObject(ref.Hash())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Fatal("Tag at commit ", ref.Hash(), " is unannotated or otherwise broken. Please fix it.")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tagcmpTag, err := tagcmp.Parse(obj.Name)
|
tagcmpTag, err := tagcmp.Parse(obj.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastGitTag == tagcmp.Tag{}) {
|
if (lastGitTag == tagcmp.Tag{}) {
|
||||||
lastGitTag = tagcmpTag
|
lastGitTag = tagcmpTag
|
||||||
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
|
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
|
||||||
lastGitTag = tagcmpTag
|
lastGitTag = tagcmpTag
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpType is used to decide what part of the tag should be incremented
|
// bumpType is used to decide what part of the tag should be incremented
|
||||||
@ -113,7 +150,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
|||||||
if bumpType != 0 {
|
if bumpType != 0 {
|
||||||
// a bitwise check if the number is a power of 2
|
// a bitwise check if the number is a power of 2
|
||||||
if (bumpType & (bumpType - 1)) != 0 {
|
if (bumpType & (bumpType - 1)) != 0 {
|
||||||
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
|
log.Fatal("you can only use one version flag: --major, --minor or --patch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,21 +159,24 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
|||||||
if internal.Patch {
|
if internal.Patch {
|
||||||
now, err := strconv.Atoi(newTag.Patch)
|
now, err := strconv.Atoi(newTag.Patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newTag.Patch = strconv.Itoa(now + 1)
|
newTag.Patch = strconv.Itoa(now + 1)
|
||||||
} else if internal.Minor {
|
} else if internal.Minor {
|
||||||
now, err := strconv.Atoi(newTag.Minor)
|
now, err := strconv.Atoi(newTag.Minor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newTag.Patch = "0"
|
newTag.Patch = "0"
|
||||||
newTag.Minor = strconv.Itoa(now + 1)
|
newTag.Minor = strconv.Itoa(now + 1)
|
||||||
} else if internal.Major {
|
} else if internal.Major {
|
||||||
now, err := strconv.Atoi(newTag.Major)
|
now, err := strconv.Atoi(newTag.Major)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newTag.Patch = "0"
|
newTag.Patch = "0"
|
||||||
newTag.Minor = "0"
|
newTag.Minor = "0"
|
||||||
newTag.Major = strconv.Itoa(now + 1)
|
newTag.Major = strconv.Itoa(now + 1)
|
||||||
@ -144,53 +184,67 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
|||||||
}
|
}
|
||||||
|
|
||||||
newTag.Metadata = mainAppVersion
|
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()
|
nextTag = newTag.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tagcmp.Parse(nextTag); err != nil {
|
if _, err := tagcmp.Parse(nextTag); err != nil {
|
||||||
logrus.Fatalf("invalid version %s specified", nextTag)
|
log.Fatalf("invalid version %s specified", nextTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainService := "app"
|
mainService := "app"
|
||||||
var services []string
|
|
||||||
hasAppService := false
|
|
||||||
for _, service := range recipe.Config.Services {
|
|
||||||
services = append(services, service.Name)
|
|
||||||
if service.Name == "app" {
|
|
||||||
hasAppService = true
|
|
||||||
logrus.Debugf("detected app service in %s", recipe.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasAppService {
|
|
||||||
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("selecting %s as the service to sync version label", mainService)
|
|
||||||
|
|
||||||
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
|
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
|
||||||
if !internal.Dry {
|
if !internal.Dry {
|
||||||
if err := recipe.UpdateLabel(mainService, label); err != nil {
|
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
logrus.Infof("synced label '%s' to service '%s'", label, mainService)
|
|
||||||
} else {
|
} else {
|
||||||
logrus.Infof("dry run only: 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)
|
||||||
},
|
|
||||||
BashComplete: func(c *cli.Context) {
|
|
||||||
catl, err := catalogue.ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Warn(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.NArg() > 0 {
|
if !isClean {
|
||||||
return
|
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
|
||||||
}
|
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
|
||||||
for name := range catl {
|
log.Fatal(err)
|
||||||
fmt.Println(name)
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@ -9,14 +10,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"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"
|
"coopcloud.tech/tagcmp"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/docker/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type imgPin struct {
|
type imgPin struct {
|
||||||
@ -24,58 +27,82 @@ type imgPin struct {
|
|||||||
version tagcmp.Tag
|
version tagcmp.Tag
|
||||||
}
|
}
|
||||||
|
|
||||||
var recipeUpgradeCommand = &cli.Command{
|
// anUpgrade represents a single service upgrade (as within a recipe), and the
|
||||||
Name: "upgrade",
|
// list of tags that it can be upgraded to, for serialization purposes.
|
||||||
Usage: "Upgrade recipe image tags",
|
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"},
|
Aliases: []string{"u"},
|
||||||
Description: `
|
Short: "Upgrade recipe image tags",
|
||||||
This command reads and attempts to parse all image tags within the given
|
Long: `Upgrade a given <recipe> configuration.
|
||||||
<recipe> configuration and prompt with more recent tags to upgrade to. It will
|
|
||||||
update the relevant compose file tags on the local file system.
|
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
|
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
|
semver-like convention. In this case, all possible tags will be listed and it
|
||||||
is up to the end-user to decide.
|
is up to the end-user to decide.
|
||||||
`,
|
|
||||||
ArgsUsage: "<recipe>",
|
The command is interactive and will show a select input which allows you to
|
||||||
Flags: []cli.Flag{
|
make a seclection. Use the "?" key to see more help on navigating this
|
||||||
internal.PatchFlag,
|
interface.
|
||||||
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 {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
recipe := internal.ValidateRecipe(c)
|
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)
|
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
|
||||||
if bumpType != 0 {
|
if bumpType != 0 {
|
||||||
// a bitwise check if the number is a power of 2
|
// a bitwise check if the number is a power of 2
|
||||||
if (bumpType & (bumpType - 1)) != 0 {
|
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
|
// check for versions file and load pinned versions
|
||||||
versionsPresent := false
|
versionsPresent := false
|
||||||
recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
|
versionsPath := path.Join(recipe.Dir, "versions")
|
||||||
versionsPath := path.Join(recipeDir, "versions")
|
servicePins := make(map[string]imgPin)
|
||||||
var servicePins = make(map[string]imgPin)
|
|
||||||
if _, err := os.Stat(versionsPath); err == nil {
|
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)
|
file, err := os.Open(versionsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
splitLine := strings.Split(line, " ")
|
splitLine := strings.Split(line, " ")
|
||||||
if splitLine[0] != "pin" || len(splitLine) != 3 {
|
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], ":")
|
pinSlice := strings.Split(splitLine[2], ":")
|
||||||
pinTag, err := tagcmp.Parse(pinSlice[1])
|
pinTag, err := tagcmp.Parse(pinSlice[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
pin := imgPin{
|
pin := imgPin{
|
||||||
image: pinSlice[0],
|
image: pinSlice[0],
|
||||||
@ -84,51 +111,54 @@ is up to the end-user to decide.
|
|||||||
servicePins[splitLine[1]] = pin
|
servicePins[splitLine[1]] = pin
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
logrus.Error(err)
|
log.Error(err)
|
||||||
}
|
}
|
||||||
versionsPresent = true
|
versionsPresent = true
|
||||||
} else {
|
} 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)
|
||||||
catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
|
if err != nil {
|
||||||
if err != nil {
|
log.Fatal(err)
|
||||||
logrus.Fatal(err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
for _, service := range config.Services {
|
||||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
regVersions, err := client.GetRegistryTags(img)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
image := reference.Path(img)
|
image := reference.Path(img)
|
||||||
regVersions, err := client.GetRegistryTags(image)
|
log.Debugf("retrieved %s from remote registry for %s", regVersions, image)
|
||||||
if err != nil {
|
image = formatter.StripTagMeta(image)
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
logrus.Debugf("retrieved '%s' from remote registry for '%s'", regVersions, image)
|
|
||||||
|
|
||||||
if strings.Contains(image, "library") {
|
switch img.(type) {
|
||||||
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
|
case reference.NamedTagged:
|
||||||
// postgres:<tag>, i.e. images which do not have a username in the
|
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
||||||
// first position of the string
|
log.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
|
||||||
image = strings.Split(image, "/")[1]
|
}
|
||||||
}
|
default:
|
||||||
semverLikeTag := true
|
log.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
|
||||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
continue
|
||||||
logrus.Debugf("'%s' not considered semver-like", img.(reference.NamedTagged).Tag())
|
|
||||||
semverLikeTag = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
|
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
|
||||||
if err != nil && semverLikeTag {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
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
|
var compatible []tagcmp.Tag
|
||||||
for _, regVersion := range regVersions {
|
for _, regVersion := range regVersions {
|
||||||
other, err := tagcmp.Parse(regVersion.Name)
|
other, err := tagcmp.Parse(regVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // skip tags that cannot be parsed
|
continue // skip tags that cannot be parsed
|
||||||
}
|
}
|
||||||
@ -138,16 +168,21 @@ is up to the end-user to decide.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
sort.Sort(tagcmp.ByTagDesc(compatible))
|
||||||
|
|
||||||
if len(compatible) == 0 && semverLikeTag {
|
if len(compatible) == 0 && !allTags {
|
||||||
logrus.Info(fmt.Sprintf("no new versions available for '%s', '%s' is the latest", image, tag))
|
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
|
continue // skip on to the next tag and don't update any compose files
|
||||||
}
|
}
|
||||||
|
|
||||||
var compatibleStrings []string
|
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
compatibleStrings := []string{"skip"}
|
||||||
for _, compat := range compatible {
|
for _, compat := range compatible {
|
||||||
skip := false
|
skip := false
|
||||||
for _, catlVersion := range catlVersions {
|
for _, catlVersion := range catlVersions {
|
||||||
@ -160,7 +195,7 @@ is up to the end-user to decide.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
var upgradeTag string
|
||||||
_, ok := servicePins[service.Name]
|
_, ok := servicePins[service.Name]
|
||||||
@ -177,13 +212,13 @@ is up to the end-user to decide.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if contains {
|
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 {
|
} 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
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -191,7 +226,7 @@ is up to the end-user to decide.
|
|||||||
for _, upTag := range compatible {
|
for _, upTag := range compatible {
|
||||||
upElement, err := tag.UpgradeDelta(upTag)
|
upElement, err := tag.UpgradeDelta(upTag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
delta := upElement.UpgradeType()
|
delta := upElement.UpgradeType()
|
||||||
if delta <= bumpType {
|
if delta <= bumpType {
|
||||||
@ -200,36 +235,147 @@ is up to the end-user to decide.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if upgradeTag == "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
|
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()
|
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)
|
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
|
||||||
compatibleStrings = []string{}
|
compatibleStrings = []string{"skip"}
|
||||||
for _, regVersion := range regVersions {
|
for _, regVersion := range regVersions {
|
||||||
compatibleStrings = append(compatibleStrings, regVersion.Name)
|
compatibleStrings = append(compatibleStrings, regVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := &survey.Select{
|
// there is always at least the item "skip" in compatibleStrings (a list of
|
||||||
Message: msg,
|
// possible upgradable tags) and at least one other tag.
|
||||||
Options: compatibleStrings,
|
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 err := recipe.UpdateTag(image, upgradeTag); err != nil {
|
if upgradeTag != "skip" {
|
||||||
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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !internal.NoInput {
|
||||||
|
log.Warnf("not upgrading %s, skipping as requested", image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logrus.Infof("tag upgraded from '%s' to '%s' for '%s'", tag.String(), upgradeTag, 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,45 +1,135 @@
|
|||||||
package recipe
|
package recipe
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/catalogue"
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/formatter"
|
||||||
"github.com/urfave/cli/v2"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var recipeVersionCommand = &cli.Command{
|
var RecipeVersionCommand = &cobra.Command{
|
||||||
Name: "versions",
|
Use: "versions <recipe> [flags]",
|
||||||
Usage: "List recipe versions",
|
Aliases: []string{"v"},
|
||||||
Aliases: []string{"v"},
|
Short: "List recipe versions",
|
||||||
ArgsUsage: "<recipe>",
|
Args: cobra.ExactArgs(1),
|
||||||
Action: func(c *cli.Context) error {
|
ValidArgsFunction: func(
|
||||||
recipe := internal.ValidateRecipe(c)
|
cmd *cobra.Command,
|
||||||
|
args []string,
|
||||||
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
return autocomplete.RecipeNameComplete()
|
||||||
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
var warnMessages []string
|
||||||
|
|
||||||
catalogue, err := catalogue.ReadRecipeCatalogue()
|
recipe := internal.ValidateRecipe(args, cmd.Name())
|
||||||
|
|
||||||
|
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
recipeMeta, ok := catalogue[recipe.Name]
|
recipeMeta, ok := catl[recipe.Name]
|
||||||
if !ok {
|
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"}
|
if len(recipeMeta.Versions) == 0 {
|
||||||
table := formatter.CreateTable(tableCol)
|
log.Fatalf("%s has no published versions?", recipe.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(recipeMeta.Versions) - 1; i >= 0; 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 _, serviceVersion := range recipeMeta.Versions {
|
|
||||||
for tag, meta := range serviceVersion {
|
|
||||||
for service, serviceMeta := range meta {
|
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 !internal.MachineReadable {
|
||||||
table.Render()
|
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"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
|
||||||
"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 := abraFormatter.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,136 +0,0 @@
|
|||||||
package record
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
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
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
record := libdns.Record{
|
|
||||||
Type: internal.DNSType,
|
|
||||||
Name: internal.DNSName,
|
|
||||||
Value: internal.DNSValue,
|
|
||||||
TTL: time.Duration(internal.DNSTTL),
|
|
||||||
}
|
|
||||||
|
|
||||||
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.Fatal("provider library reports that this record already exists?")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createdRecords, err := provider.SetRecords(
|
|
||||||
c.Context,
|
|
||||||
zone,
|
|
||||||
[]libdns.Record{record},
|
|
||||||
)
|
|
||||||
|
|
||||||
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 := abraFormatter.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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -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 via 3rd party providers",
|
|
||||||
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,130 +0,0 @@
|
|||||||
package record
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
|
||||||
"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 := abraFormatter.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()
|
|
||||||
|
|
||||||
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,510 +1,203 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"os/user"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
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/server"
|
||||||
"coopcloud.tech/abra/pkg/ssh"
|
sshPkg "coopcloud.tech/abra/pkg/ssh"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/spf13/cobra"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var ServerAddCommand = &cobra.Command{
|
||||||
dockerInstallMsg = `
|
Use: "add [[server] | --local] [flags]",
|
||||||
A docker installation cannot be found on %s. This is a required system
|
Aliases: []string{"a"},
|
||||||
dependency for running Co-op Cloud on your server. If you would like, Abra can
|
Short: "Add a new server",
|
||||||
attempt to install Docker for you using the upstream non-interactive
|
Long: `Add a new server to your configuration so that it can be managed by Abra.
|
||||||
installation script.
|
|
||||||
|
|
||||||
See the following documentation for more:
|
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:
|
||||||
|
|
||||||
https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script
|
Host 1312.net 1312
|
||||||
|
Hostname 1312.net
|
||||||
N.B Docker doesn't recommend it for production environments but many use it for
|
User antifa
|
||||||
such purposes. Docker stable is now installed by default by this script. The
|
Port 12345
|
||||||
source for this script can be seen here:
|
IdentityFile ~/.ssh/antifa@somewhere
|
||||||
|
|
||||||
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.ABRA_SERVER_FOLDER, 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("bash", "-c", "curl -s 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 {
|
|
||||||
out, err := exec.Command("which", "docker").Output()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if string(out) == "" {
|
|
||||||
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 {
|
|
||||||
result, err := sshCl.Exec("which docker")
|
|
||||||
if err != nil && string(result) != "" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(result) == "" {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := "curl -s https://get.docker.com | bash"
|
|
||||||
|
|
||||||
var sudoPass string
|
|
||||||
if askSudoPass {
|
|
||||||
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 err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logrus.Debugf("running '%s' on %s now without sudo password", cmd, domainName)
|
|
||||||
if err := ssh.Exec(cmd, sshCl); err != nil {
|
|
||||||
return 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") {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logrus.Info("swarm mode already initialised on local server")
|
|
||||||
} 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 {
|
|
||||||
// comrade librehosters DNS resolver -> https://www.privacy-handbuch.de/handbuch_93d.htm
|
|
||||||
freifunkDNS := "5.1.66.255:53"
|
|
||||||
|
|
||||||
resolver := &net.Resolver{
|
|
||||||
PreferGo: false,
|
|
||||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
||||||
d := net.Dialer{
|
|
||||||
Timeout: time.Millisecond * time.Duration(10000),
|
|
||||||
}
|
|
||||||
return d.DialContext(ctx, "udp", freifunkDNS)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
|
|
||||||
|
|
||||||
ips, err := resolver.LookupIPAddr(c.Context, domainName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipv4 := ips[0].IP.To4().String()
|
|
||||||
logrus.Debugf("discovered the following ipv4 addr: %s", ipv4)
|
|
||||||
|
|
||||||
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") {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logrus.Infof("swarm mode already initialised on %s", domainName)
|
|
||||||
} 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) {
|
|
||||||
fmt.Println(fmt.Sprintf(`
|
|
||||||
You specified "--traefik/-t" and that means that Abra will now try to
|
|
||||||
automatically create a new Traefik app on %s.
|
|
||||||
`, internal.NewAppServer))
|
|
||||||
|
|
||||||
tableCol := []string{"recipe", "domain", "server", "name"}
|
|
||||||
table := abraFormatter.CreateTable(tableCol)
|
|
||||||
table.Append([]string{internal.RecipeName, internal.Domain, internal.NewAppServer, internal.NewAppName})
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
If "--local" is passed, then Abra assumes that the current local server is
|
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
|
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
|
Co-op Cloud config located on the server itself, and not on your local
|
||||||
developer machine.
|
developer machine. The domain is then set to "default".`,
|
||||||
|
Example: " abra server add 1312.net",
|
||||||
Example:
|
Args: cobra.RangeArgs(0, 1),
|
||||||
|
ValidArgsFunction: func(
|
||||||
abra server add --local
|
cmd *cobra.Command,
|
||||||
|
args []string,
|
||||||
Otherwise, you may specify a remote server. The <domain> argument must be a
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
publicy accessible domain name which points to your server. You should have SSH
|
if !local {
|
||||||
access to this server, Abra will assume port 22 and will use your current
|
return autocomplete.ServerNameComplete()
|
||||||
system username to make an initial connection. You can use the <user> and
|
}
|
||||||
<port> arguments to adjust this.
|
return nil, cobra.ShellCompDirectiveDefault
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
ArgsUsage: "<domain> [<user>] [<port>]",
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
Action: func(c *cli.Context) error {
|
if len(args) > 0 && local {
|
||||||
if c.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(c) {
|
log.Fatal("cannot use [server] and --local together")
|
||||||
err := errors.New("cannot use '<domain>' and '--local' together")
|
|
||||||
internal.ShowSubcommandHelpAndError(c, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sshAuth != "password" && sshAuth != "identity-file" {
|
if len(args) == 0 && !local {
|
||||||
err := errors.New("--ssh-auth only accepts 'identity-file' or 'password'")
|
log.Fatal("missing argument or --local/-l flag")
|
||||||
internal.ShowSubcommandHelpAndError(c, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 local {
|
||||||
if err := newLocalServer(c, "default"); err != nil {
|
created, err := createServerDir(name)
|
||||||
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()
|
|
||||||
if err != nil {
|
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)
|
_, err := createServerDir(name)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if provision {
|
created, err := newContext(name)
|
||||||
logrus.Debugf("attempting to construct SSH client for %s", domainName)
|
if err != nil {
|
||||||
sshCl, err := ssh.New(domainName, sshAuth, username, port)
|
cleanUp(name)
|
||||||
if err != nil {
|
log.Fatalf("unable to create local context: %s", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := cl.Info(c.Context); err != nil {
|
log.Debugf("attempting to create client for %s", name)
|
||||||
cleanUp(domainName)
|
|
||||||
logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
|
if _, err := client.New(name, timeout); err != nil {
|
||||||
|
cleanUp(name)
|
||||||
|
log.Fatalf("ssh %s error: %s", name, sshPkg.Fatal(name, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if traefik {
|
if created {
|
||||||
if err := deployTraefik(c, cl, domainName); err != nil {
|
log.Infof("%s successfully added", name)
|
||||||
logrus.Fatal(err)
|
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, context := range contexts {
|
||||||
|
if context.Name == name {
|
||||||
|
log.Debugf("context for %s already exists", name)
|
||||||
|
return false, 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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"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/docker/cli/cli/connhelper/ssh"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var serverListCommand = &cli.Command{
|
var ServerListCommand = &cobra.Command{
|
||||||
Name: "list",
|
Use: "list [flags]",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Usage: "List managed servers",
|
Short: "List managed servers",
|
||||||
ArgsUsage: " ",
|
Args: cobra.NoArgs,
|
||||||
HideHelp: true,
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
Action: func(c *cli.Context) error {
|
dockerContextStore := contextPkg.NewDefaultDockerContextStore()
|
||||||
dockerContextStore := context.NewDefaultDockerContextStore()
|
|
||||||
contexts, err := dockerContextStore.Store.List()
|
contexts, err := dockerContextStore.Store.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tableColumns := []string{"name", "host", "user", "port"}
|
table, err := formatter.CreateTable()
|
||||||
table := formatter.CreateTable(tableColumns)
|
if err != nil {
|
||||||
defer table.Render()
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := []string{"NAME", "HOST"}
|
||||||
|
table.Headers(headers...)
|
||||||
|
|
||||||
serverNames, err := config.ReadServerNames()
|
serverNames, err := config.ReadServerNames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rows [][]string
|
||||||
for _, serverName := range serverNames {
|
for _, serverName := range serverNames {
|
||||||
var row []string
|
var row []string
|
||||||
for _, ctx := range contexts {
|
for _, dockerCtx := range contexts {
|
||||||
endpoint, err := context.GetContextEndpoint(ctx)
|
endpoint, err := contextPkg.GetContextEndpoint(dockerCtx)
|
||||||
if err != nil && strings.Contains(err.Error(), "does not exist") {
|
if err != nil && strings.Contains(err.Error(), "does not exist") {
|
||||||
// No local context found, we can continue safely
|
// No local context found, we can continue safely
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ctx.Name == serverName {
|
|
||||||
|
if dockerCtx.Name == serverName {
|
||||||
sp, err := ssh.ParseURL(endpoint)
|
sp, err := ssh.ParseURL(endpoint)
|
||||||
if err != nil {
|
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 len(row) == 0 {
|
||||||
if serverName == "default" {
|
if serverName == "default" {
|
||||||
row = []string{serverName, "local", "n/a", "n/a"}
|
row = []string{serverName, "local"}
|
||||||
} else {
|
} 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,236 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
|
||||||
"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},
|
|
||||||
}
|
|
||||||
|
|
||||||
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
|
|
||||||
table := formatter.CreateTable(tableColumns)
|
|
||||||
table.Append([]string{
|
|
||||||
internal.HetznerCloudName,
|
|
||||||
internal.HetznerCloudType,
|
|
||||||
internal.HetznerCloudImage,
|
|
||||||
strings.Join(sshKeysRaw, "\n"),
|
|
||||||
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.
|
|
||||||
`,
|
|
||||||
internal.HetznerCloudName, ip, rootPassword,
|
|
||||||
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.
|
|
||||||
`, 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.
|
|
||||||
`,
|
|
||||||
ArgsUsage: "<provider>",
|
|
||||||
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,159 +1,46 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/formatter"
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
|
"coopcloud.tech/abra/pkg/autocomplete"
|
||||||
"coopcloud.tech/abra/pkg/client"
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
"github.com/spf13/cobra"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var rmServer bool
|
var ServerRemoveCommand = &cobra.Command{
|
||||||
var rmServerFlag = &cli.BoolFlag{
|
Use: "remove <server> [flags]",
|
||||||
Name: "server",
|
Aliases: []string{"rm"},
|
||||||
Aliases: []string{"s"},
|
Short: "Remove a managed server",
|
||||||
Value: false,
|
Long: `Remove a managed server.
|
||||||
Usage: "remove the actual server also",
|
|
||||||
Destination: &rmServer,
|
|
||||||
}
|
|
||||||
|
|
||||||
func rmHetznerCloudVPS(c *cli.Context) error {
|
Abra will remove the internal bookkeeping ($ABRA_DIR/servers/...) and
|
||||||
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
|
|
||||||
underlying client connection context. This server will then be lost in time,
|
underlying client connection context. This server will then be lost in time,
|
||||||
like tears in rain.
|
like tears in rain.`,
|
||||||
`,
|
Args: cobra.ExactArgs(1),
|
||||||
Flags: []cli.Flag{
|
ValidArgsFunction: func(
|
||||||
rmServerFlag,
|
cmd *cobra.Command,
|
||||||
|
args []string,
|
||||||
// Hetzner
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
internal.HetznerCloudNameFlag,
|
return autocomplete.ServerNameComplete()
|
||||||
internal.HetznerCloudAPITokenFlag,
|
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
serverName, err := internal.ValidateServer(c)
|
serverName := internal.ValidateServer(args)
|
||||||
if err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rmServer {
|
|
||||||
if err := internal.EnsureServerProvider(); err != nil {
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 err := client.DeleteContext(serverName); err != nil {
|
if err := client.DeleteContext(serverName); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, serverName)); err != nil {
|
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
|
||||||
logrus.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("server at '%s' has been lost in time, like tears in rain", serverName)
|
log.Infof("%s is now lost in time, like tears in rain", serverName)
|
||||||
|
|
||||||
return nil
|
return
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import "github.com/spf13/cobra"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServerCommand defines the `abra server` command and its subcommands
|
// ServerCommand defines the `abra server` command and its subcommands
|
||||||
var ServerCommand = &cli.Command{
|
var ServerCommand = &cobra.Command{
|
||||||
Name: "server",
|
Use: "server [cmd] [args] [flags]",
|
||||||
Aliases: []string{"s"},
|
Aliases: []string{"s"},
|
||||||
Usage: "Manage servers via 3rd party providers",
|
Short: "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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,23 +1,56 @@
|
|||||||
|
// Package cli provides the interface for the command-line.
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"coopcloud.tech/abra/cli/internal"
|
"coopcloud.tech/abra/cli/internal"
|
||||||
"github.com/sirupsen/logrus"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpgradeCommand upgrades abra in-place.
|
// UpgradeCommand upgrades abra in-place.
|
||||||
var UpgradeCommand = &cli.Command{
|
var UpgradeCommand = &cobra.Command{
|
||||||
Name: "upgrade",
|
Use: "upgrade [flags]",
|
||||||
Usage: "Upgrade abra",
|
Aliases: []string{"u"},
|
||||||
Action: func(c *cli.Context) error {
|
Short: "Upgrade abra",
|
||||||
cmd := exec.Command("bash", "-c", "curl -s https://install.abra.coopcloud.tech | bash")
|
Long: `Upgrade abra in-place with the latest stable or release candidate.
|
||||||
logrus.Debugf("attempting to run '%s'", cmd)
|
|
||||||
if err := internal.RunCmd(cmd); err != nil {
|
By default, the latest stable release is downloaded.
|
||||||
logrus.Fatal(err)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
releaseCandidate bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
UpgradeCommand.Flags().BoolVarP(
|
||||||
|
&releaseCandidate,
|
||||||
|
"rc",
|
||||||
|
"r",
|
||||||
|
false,
|
||||||
|
"install release candidate (may contain bugs)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,14 +5,13 @@ import (
|
|||||||
"coopcloud.tech/abra/cli"
|
"coopcloud.tech/abra/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the current version of abra.
|
// Version is the current version of Abra.
|
||||||
var Version string
|
var Version string
|
||||||
|
|
||||||
// Commit is the current commit of abra.
|
// Commit is the current git commit of Abra.
|
||||||
var Commit string
|
var Commit string
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// If not set in the ld-flags
|
|
||||||
if Version == "" {
|
if Version == "" {
|
||||||
Version = "dev"
|
Version = "dev"
|
||||||
}
|
}
|
||||||
@ -20,5 +19,5 @@ func main() {
|
|||||||
Commit = " "
|
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)
|
||||||
|
}
|
||||||
188
go.mod
188
go.mod
@ -1,47 +1,163 @@
|
|||||||
module coopcloud.tech/abra
|
module coopcloud.tech/abra
|
||||||
|
|
||||||
go 1.16
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
|
coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.1
|
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c
|
||||||
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
|
github.com/charmbracelet/bubbletea v1.3.6
|
||||||
github.com/docker/cli v20.10.8+incompatible
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/docker/distribution v2.7.1+incompatible
|
github.com/charmbracelet/log v0.4.2
|
||||||
github.com/docker/docker v20.10.8+incompatible
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/go-units v0.4.0
|
github.com/docker/cli v28.3.3+incompatible
|
||||||
github.com/go-git/go-git/v5 v5.4.2
|
github.com/docker/docker v28.3.3+incompatible
|
||||||
github.com/hetznercloud/hcloud-go v1.32.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/moby/sys/signal v0.5.0
|
github.com/go-git/go-git/v5 v5.16.2
|
||||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/olekukonko/tablewriter v0.0.5
|
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/pkg/errors v0.9.1
|
||||||
github.com/schollz/progressbar/v3 v3.8.3
|
github.com/schollz/progressbar/v3 v3.18.0
|
||||||
github.com/schultz-is/passgen v1.0.1
|
golang.org/x/term v0.34.0
|
||||||
github.com/sirupsen/logrus v1.8.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
github.com/urfave/cli/v2 v2.3.0
|
gotest.tools/v3 v3.5.2
|
||||||
gotest.tools/v3 v3.0.3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Microsoft/hcsshim v0.8.21 // indirect
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
|
||||||
github.com/containerd/containerd v1.5.5 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.6.4 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/fvbommel/sortorder v1.0.2 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/gliderlabs/ssh v0.2.2
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/gorilla/mux v1.8.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/libdns/gandi v1.0.2
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/libdns/libdns v0.2.1
|
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||||
github.com/moby/sys/mount v0.2.0 // 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/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/opencontainers/runc v1.0.2 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/theupdateframework/notary v0.7.0 // 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
|
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
|
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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"coopcloud.tech/abra/pkg/client"
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"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"
|
"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
|
// Get retrieves an app
|
||||||
func Get(appName string) (config.App, error) {
|
func Get(appName string) (App, error) {
|
||||||
files, err := config.LoadAppFiles("")
|
files, err := LoadAppFiles("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config.App{}, err
|
return App{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := config.GetApp(files, appName)
|
app, err := GetApp(files, appName)
|
||||||
if err != nil {
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deployedServiceSpec represents a deployed service of an app.
|
// GetApp loads an apps settings, reading it from file, in preparation to use
|
||||||
type deployedServiceSpec struct {
|
// it. It should only be used when ready to use the env file to keep IO
|
||||||
Name string
|
// operations down.
|
||||||
Version string
|
func GetApp(apps AppFiles, name AppName) (App, error) {
|
||||||
}
|
appFile, exists := apps[name]
|
||||||
|
if !exists {
|
||||||
// VersionSpec represents a deployed app and associated metadata.
|
return App{}, fmt.Errorf("cannot find app with name %s", name)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appSpec := make(VersionSpec)
|
app, err := ReadAppEnvFile(appFile, name)
|
||||||
for _, service := range services {
|
if err != nil {
|
||||||
serviceName := ParseServiceName(service.Spec.Name)
|
return App{}, err
|
||||||
label := fmt.Sprintf("coop-cloud.%s.%s.version", app.StackName(), serviceName)
|
}
|
||||||
if deployLabel, ok := service.Spec.Labels[label]; ok {
|
|
||||||
version, _ := ParseVersionLabel(deployLabel)
|
return app, nil
|
||||||
appSpec[serviceName] = deployedServiceSpec{Name: serviceName, Version: version}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
// App reprents an app with its env file read into memory
|
||||||
logrus.Debugf("detected '%s' as deployed versions of '%s'", appSpec, app.Name)
|
type App struct {
|
||||||
} else {
|
Name AppName
|
||||||
logrus.Debugf("detected '%s' as not deployed", app.Name)
|
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.
|
// StackName gets whatever the docker safe (uses the right delimiting
|
||||||
func ParseVersionLabel(label string) (string, string) {
|
// character, e.g. "_") stack name is for the app. In general, you don't want
|
||||||
// versions may look like v4.2-abcd or v4.2-alpine-abcd
|
// to use this to show anything to end-users, you want use a.Name instead.
|
||||||
idx := strings.LastIndex(label, "-")
|
func StackName(appName string) string {
|
||||||
version := label[:idx]
|
stackName := SanitiseAppName(appName)
|
||||||
digest := label[idx+1:]
|
|
||||||
logrus.Debugf("parsed '%s' as version from '%s'", version, label)
|
if len(stackName) > config.MAX_SANITISED_APP_NAME_LENGTH {
|
||||||
logrus.Debugf("parsed '%s' as digest from '%s'", digest, label)
|
log.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:config.MAX_SANITISED_APP_NAME_LENGTH])
|
||||||
return version, digest
|
stackName = stackName[:config.MAX_SANITISED_APP_NAME_LENGTH]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackName
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseVersionName parses a $STACK_NAME_$SERVICE_NAME service label.
|
// Filters retrieves app filters for querying the container runtime. By default
|
||||||
func ParseServiceName(label string) string {
|
// it filters on all services in the app. It is also possible to pass an
|
||||||
idx := strings.LastIndex(label, "_")
|
// otional list of service names, which get filtered instead.
|
||||||
serviceName := label[idx+1:]
|
//
|
||||||
logrus.Debugf("parsed '%s' as service name from '%s'", serviceName, label)
|
// Due to upstream issues, filtering works different depending on what you're
|
||||||
return serviceName
|
// 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
|
||||||
124
pkg/autocomplete/autocomplete.go
Normal file
124
pkg/autocomplete/autocomplete.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package autocomplete
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"coopcloud.tech/abra/pkg/app"
|
||||||
|
appPkg "coopcloud.tech/abra/pkg/app"
|
||||||
|
"coopcloud.tech/abra/pkg/recipe"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppNameComplete copletes app names.
|
||||||
|
func AppNameComplete() ([]string, cobra.ShellCompDirective) {
|
||||||
|
appFiles, err := app.LoadAppFiles("")
|
||||||
|
if err != nil {
|
||||||
|
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||||
|
return []string{err}, cobra.ShellCompDirectiveError
|
||||||
|
}
|
||||||
|
|
||||||
|
var appNames []string
|
||||||
|
for appName := range appFiles {
|
||||||
|
appNames = append(appNames, appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return appNames, cobra.ShellCompDirectiveDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServiceNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
serviceNames, err := app.GetAppServiceNames(appName)
|
||||||
|
if err != nil {
|
||||||
|
err := fmt.Sprintf("autocomplete failed: %s", err)
|
||||||
|
return []string{err}, cobra.ShellCompDirectiveError
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -1,500 +1,87 @@
|
|||||||
// Package catalogue provides ways of interacting with recipe catalogues which
|
|
||||||
// are JSON data structures which contain meta information about recipes (e.g.
|
|
||||||
// what versions of the Nextcloud recipe are available?).
|
|
||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/pkg/client"
|
|
||||||
"coopcloud.tech/abra/pkg/config"
|
"coopcloud.tech/abra/pkg/config"
|
||||||
"coopcloud.tech/abra/pkg/recipe"
|
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||||
"coopcloud.tech/abra/pkg/web"
|
"coopcloud.tech/abra/pkg/log"
|
||||||
"github.com/docker/distribution/reference"
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RecipeCatalogueURL is the only current recipe catalogue available.
|
// EnsureCatalogue ensures that the catalogue is cloned locally & present.
|
||||||
const RecipeCatalogueURL = "https://apps.coopcloud.tech"
|
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")
|
||||||
|
|
||||||
// ReposMetadataURL is the recipe repository metadata
|
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
|
||||||
const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
|
if err := gitPkg.Clone(catalogueDir, url); err != nil {
|
||||||
|
return err
|
||||||
// image represents a recipe container image.
|
|
||||||
type image struct {
|
|
||||||
Image string `json:"image"`
|
|
||||||
Rating string `json:"rating"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// features represent what top-level features a recipe supports (e.g. does this
|
|
||||||
// recipe support backups?).
|
|
||||||
type features struct {
|
|
||||||
Backups string `json:"backups"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Healthcheck string `json:"healthcheck"`
|
|
||||||
Image image `json:"image"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
Tests string `json:"tests"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// tag represents a git tag.
|
|
||||||
type tag = string
|
|
||||||
|
|
||||||
// service represents a service within a recipe.
|
|
||||||
type service = string
|
|
||||||
|
|
||||||
// ServiceMeta represents meta info associated with a service.
|
|
||||||
type ServiceMeta struct {
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecipeVersions are the versions associated with a recipe.
|
|
||||||
type RecipeVersions []map[tag]map[service]ServiceMeta
|
|
||||||
|
|
||||||
// RecipeMeta represents metadata for a recipe in the abra catalogue.
|
|
||||||
type RecipeMeta struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Features features `json:"features"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Versions RecipeVersions `json:"versions"`
|
|
||||||
Website string `json:"website"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LatestVersion returns the latest version of a recipe.
|
|
||||||
func (r RecipeMeta) LatestVersion() string {
|
|
||||||
var version string
|
|
||||||
|
|
||||||
// apps.json versions are sorted so the last key is latest
|
|
||||||
latest := r.Versions[len(r.Versions)-1]
|
|
||||||
|
|
||||||
for tag := range latest {
|
|
||||||
version = tag
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("choosing '%s' as latest version of '%s'", version, r.Name)
|
|
||||||
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name represents a recipe name.
|
|
||||||
type Name = string
|
|
||||||
|
|
||||||
// RecipeCatalogue represents the entire recipe catalogue.
|
|
||||||
type RecipeCatalogue map[Name]RecipeMeta
|
|
||||||
|
|
||||||
// Flatten converts AppCatalogue to slice
|
|
||||||
func (r RecipeCatalogue) Flatten() []RecipeMeta {
|
|
||||||
recipes := make([]RecipeMeta, 0, len(r))
|
|
||||||
|
|
||||||
for name := range r {
|
|
||||||
recipes = append(recipes, r[name])
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ByRecipeName sorts recipes by name.
|
|
||||||
type ByRecipeName []RecipeMeta
|
|
||||||
|
|
||||||
func (r ByRecipeName) Len() int { return len(r) }
|
|
||||||
func (r ByRecipeName) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
|
|
||||||
func (r ByRecipeName) Less(i, j int) bool {
|
|
||||||
return strings.ToLower(r[i].Name) < strings.ToLower(r[j].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally
|
|
||||||
// is up to date.
|
|
||||||
func recipeCatalogueFSIsLatest() (bool, error) {
|
|
||||||
httpClient := &http.Client{Timeout: web.Timeout}
|
|
||||||
res, err := httpClient.Head(RecipeCatalogueURL)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lastModified := res.Header["Last-Modified"][0]
|
|
||||||
parsed, err := time.Parse(time.RFC1123, lastModified)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(config.APPS_JSON)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
logrus.Debugf("no recipe catalogue found in file system cache")
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localModifiedTime := info.ModTime().Unix()
|
|
||||||
remoteModifiedTime := parsed.Unix()
|
|
||||||
|
|
||||||
if localModifiedTime < remoteModifiedTime {
|
|
||||||
logrus.Debug("file system cached recipe catalogue is out-of-date")
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debug("file system cached recipe catalogue is up-to-date")
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRecipeCatalogue reads the recipe catalogue.
|
|
||||||
func ReadRecipeCatalogue() (RecipeCatalogue, error) {
|
|
||||||
recipes := make(RecipeCatalogue)
|
|
||||||
|
|
||||||
recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !recipeFSIsLatest {
|
|
||||||
logrus.Debugf("reading recipe catalogue from web to get latest")
|
|
||||||
if err := readRecipeCatalogueWeb(&recipes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return recipes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("reading recipe catalogue from file system cache to get latest")
|
|
||||||
if err := readRecipeCatalogueFS(&recipes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRecipeCatalogueFS reads the catalogue from the file system.
|
|
||||||
func readRecipeCatalogueFS(target interface{}) error {
|
|
||||||
recipesJSONFS, err := ioutil.ReadFile(config.APPS_JSON)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(recipesJSONFS, &target); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("read recipe catalogue from file system cache in '%s'", config.APPS_JSON)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readRecipeCatalogueWeb reads the catalogue from the web.
|
// EnsureIsClean makes sure that the catalogue has no unstaged changes.
|
||||||
func readRecipeCatalogueWeb(target interface{}) error {
|
func EnsureIsClean() error {
|
||||||
if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil {
|
isClean, err := gitPkg.IsClean(config.CATALOGUE_DIR)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
recipesJSON, err := json.MarshalIndent(target, "", " ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
|
if !isClean {
|
||||||
return err
|
msg := "%s has locally unstaged changes? please commit/remove your changes before proceeding"
|
||||||
|
return fmt.Errorf(msg, config.CATALOGUE_DIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debugf("read recipe catalogue from web at '%s'", RecipeCatalogueURL)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionsOfService lists the version of a service.
|
// EnsureUpToDate ensures that the local catalogue is up to date.
|
||||||
func VersionsOfService(recipe, serviceName string) ([]string, error) {
|
func EnsureUpToDate() error {
|
||||||
catalogue, err := ReadRecipeCatalogue()
|
repo, err := git.PlainOpen(config.CATALOGUE_DIR)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, ok := catalogue[recipe]
|
remotes, err := repo.Remotes()
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("recipe '%s' does not exist?", recipe)
|
|
||||||
}
|
|
||||||
|
|
||||||
versions := []string{}
|
|
||||||
alreadySeen := make(map[string]bool)
|
|
||||||
for _, serviceVersion := range rec.Versions {
|
|
||||||
for tag := range serviceVersion {
|
|
||||||
if _, ok := alreadySeen[tag]; !ok {
|
|
||||||
alreadySeen[tag] = true
|
|
||||||
versions = append(versions, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("detected versions '%s' for '%s'", strings.Join(versions, ", "), recipe)
|
|
||||||
|
|
||||||
return versions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecipeMeta retrieves the recipe metadata from the recipe catalogue.
|
|
||||||
func GetRecipeMeta(recipeName string) (RecipeMeta, error) {
|
|
||||||
catl, err := ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RecipeMeta{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipeMeta, ok := catl[recipeName]
|
if len(remotes) == 0 {
|
||||||
if !ok {
|
msg := "cannot ensure %s is up-to-date, no git remotes configured"
|
||||||
err := fmt.Errorf("recipe '%s' does not exist?", recipeName)
|
log.Debugf(msg, config.CATALOGUE_DIR)
|
||||||
return RecipeMeta{}, err
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
if err := recipe.EnsureExists(recipeName); err != nil {
|
|
||||||
return RecipeMeta{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("recipe metadata retrieved for '%s'", recipeName)
|
|
||||||
|
|
||||||
return recipeMeta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepoMeta is a single recipe repo metadata.
|
|
||||||
type RepoMeta struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Owner Owner
|
|
||||||
Name string `json:"name"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Empty bool `json:"empty"`
|
|
||||||
Private bool `json:"private"`
|
|
||||||
Fork bool `json:"fork"`
|
|
||||||
Template bool `json:"template"`
|
|
||||||
Parent interface{} `json:"parent"`
|
|
||||||
Mirror bool `json:"mirror"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
SSHURL string `json:"ssh_url"`
|
|
||||||
CloneURL string `json:"clone_url"`
|
|
||||||
OriginalURL string `json:"original_url"`
|
|
||||||
Website string `json:"website"`
|
|
||||||
StarsCount int `json:"stars_count"`
|
|
||||||
ForksCount int `json:"forks_count"`
|
|
||||||
WatchersCount int `json:"watchers_count"`
|
|
||||||
OpenIssuesCount int `json:"open_issues_count"`
|
|
||||||
OpenPRCount int `json:"open_pr_counter"`
|
|
||||||
ReleaseCounter int `json:"release_counter"`
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
|
||||||
Archived bool `json:"archived"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
Permissions Permissions
|
|
||||||
HasIssues bool `json:"has_issues"`
|
|
||||||
InternalTracker InternalTracker
|
|
||||||
HasWiki bool `json:"has_wiki"`
|
|
||||||
HasPullRequests bool `json:"has_pull_requests"`
|
|
||||||
HasProjects bool `json:"has_projects"`
|
|
||||||
IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
|
|
||||||
AllowMergeCommits bool `json:"allow_merge_commits"`
|
|
||||||
AllowRebase bool `json:"allow_rebase"`
|
|
||||||
AllowRebaseExplicit bool `json:"allow_rebase_explicit"`
|
|
||||||
AllowSquashMerge bool `json:"allow_squash_merge"`
|
|
||||||
AvatarURL string `json:"avatar_url"`
|
|
||||||
Internal bool `json:"internal"`
|
|
||||||
MirrorInterval string `json:"mirror_interval"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owner is the repo organisation owner metadata.
|
|
||||||
type Owner struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Login string `json:"login"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
AvatarURL string `json:"avatar_url"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
LastLogin string `json:"last_login"`
|
|
||||||
Created string `json:"created"`
|
|
||||||
Restricted bool `json:"restricted"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permissions is perms metadata for a repo.
|
|
||||||
type Permissions struct {
|
|
||||||
Admin bool `json:"admin"`
|
|
||||||
Push bool `json:"push"`
|
|
||||||
Pull bool `json:"pull"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InternalTracker is issue tracker metadata for a repo.
|
|
||||||
type InternalTracker struct {
|
|
||||||
EnableTimeTracker bool `json:"enable_time_tracker"`
|
|
||||||
AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
|
|
||||||
EnableIssuesDependencies bool `json:"enable_issue_dependencies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepoCatalogue represents all the recipe repo metadata.
|
|
||||||
type RepoCatalogue map[string]RepoMeta
|
|
||||||
|
|
||||||
// ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea.
|
|
||||||
func ReadReposMetadata() (RepoCatalogue, error) {
|
|
||||||
reposMeta := make(RepoCatalogue)
|
|
||||||
|
|
||||||
pageIdx := 1
|
|
||||||
for {
|
|
||||||
var reposList []RepoMeta
|
|
||||||
|
|
||||||
pagedURL := fmt.Sprintf("%s?page=%v", ReposMetadataURL, pageIdx)
|
|
||||||
|
|
||||||
logrus.Debugf("fetching repo metadata from '%s'", pagedURL)
|
|
||||||
|
|
||||||
if err := web.ReadJSON(pagedURL, &reposList); err != nil {
|
|
||||||
return reposMeta, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(reposList) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, repo := range reposList {
|
|
||||||
reposMeta[repo.Name] = reposList[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
pageIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
return reposMeta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecipeVersions retrieves all recipe versions.
|
|
||||||
func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
|
|
||||||
versions := RecipeVersions{}
|
|
||||||
|
|
||||||
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
|
|
||||||
|
|
||||||
logrus.Debugf("attempting to open git repository in '%s'", recipeDir)
|
|
||||||
|
|
||||||
repo, err := git.PlainOpen(recipeDir)
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
worktree, err := repo.Worktree()
|
worktree, err := repo.Worktree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gitTags, err := repo.Tags()
|
branch, err := gitPkg.CheckoutDefaultBranch(repo, config.CATALOGUE_DIR)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
|
opts := &git.PullOptions{
|
||||||
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
|
Force: true,
|
||||||
|
ReferenceName: branch,
|
||||||
|
}
|
||||||
|
|
||||||
logrus.Debugf("processing '%s' for '%s'", tag, recipeName)
|
if err := worktree.Pull(opts); err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "already up-to-date") {
|
||||||
checkOutOpts := &git.CheckoutOptions{
|
|
||||||
Create: false,
|
|
||||||
Force: true,
|
|
||||||
Branch: plumbing.ReferenceName(ref.Name()),
|
|
||||||
}
|
|
||||||
if err := worktree.Checkout(checkOutOpts); err != nil {
|
|
||||||
logrus.Debugf("failed to check out '%s' in '%s'", tag, recipeDir)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Debugf("successfully checked out '%s' in '%s'", ref.Name(), recipeDir)
|
|
||||||
|
|
||||||
recipe, err := recipe.Get(recipeName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
versionMeta := make(map[string]ServiceMeta)
|
|
||||||
for _, service := range recipe.Config.Services {
|
|
||||||
|
|
||||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
path := reference.Path(img)
|
|
||||||
if strings.Contains(path, "library") {
|
|
||||||
path = strings.Split(path, "/")[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
digest, err := client.GetTagDigest(img)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
versionMeta[service.Name] = ServiceMeta{
|
|
||||||
Digest: digest,
|
|
||||||
Image: path,
|
|
||||||
Tag: img.(reference.NamedTagged).Tag(),
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("collecting digest: '%s', image: '%s', tag: '%s'", digest, path, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
branch := "master"
|
log.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR)
|
||||||
if _, err := repo.Branch("master"); err != nil {
|
|
||||||
if _, err := repo.Branch("main"); err != nil {
|
|
||||||
logrus.Debugf("failed to select branch in '%s'", recipeDir)
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
branch = "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
refName := fmt.Sprintf("refs/heads/%s", branch)
|
return nil
|
||||||
checkOutOpts := &git.CheckoutOptions{
|
|
||||||
Create: false,
|
|
||||||
Force: true,
|
|
||||||
Branch: plumbing.ReferenceName(refName),
|
|
||||||
}
|
|
||||||
if err := worktree.Checkout(checkOutOpts); err != nil {
|
|
||||||
logrus.Debugf("failed to check out '%s' in '%s'", branch, recipeDir)
|
|
||||||
logrus.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logrus.Debugf("switched back to '%s' in '%s'", branch, recipeDir)
|
|
||||||
logrus.Debugf("collected '%s' for '%s'", versions, recipeName)
|
|
||||||
|
|
||||||
return versions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue.
|
|
||||||
func GetRecipeCatalogueVersions(recipeName string) ([]string, error) {
|
|
||||||
var versions []string
|
|
||||||
|
|
||||||
catl, err := ReadRecipeCatalogue()
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if recipeMeta, exists := catl[recipeName]; exists {
|
|
||||||
for _, versionMeta := range recipeMeta.Versions {
|
|
||||||
for tag := range versionMeta {
|
|
||||||
versions = append(versions, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return versions, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,24 +2,46 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
|
sshPkg "coopcloud.tech/abra/pkg/ssh"
|
||||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// New initiates a new Docker client.
|
// Conf is a Docker client configuration.
|
||||||
func New(contextName string) (*client.Client, error) {
|
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
|
var clientOpts []client.Opt
|
||||||
|
|
||||||
if contextName != "default" {
|
if serverName != "default" {
|
||||||
context, err := GetContext(contextName)
|
context, err := GetContext(serverName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("unknown server, run \"abra server add %s\"?", serverName)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
|
ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
|
||||||
@ -27,9 +49,17 @@ func New(contextName string) (*client.Client, error) {
|
|||||||
return nil, err
|
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{
|
httpClient := &http.Client{
|
||||||
// No tls, no proxy
|
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: helper.Dialer,
|
DialContext: helper.Dialer,
|
||||||
IdleConnTimeout: 30 * time.Second,
|
IdleConnTimeout: 30 * time.Second,
|
||||||
@ -55,7 +85,20 @@ func New(contextName string) (*client.Client, error) {
|
|||||||
return nil, err
|
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
|
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"
|
"fmt"
|
||||||
|
|
||||||
"coopcloud.tech/abra/pkg/context"
|
"coopcloud.tech/abra/pkg/context"
|
||||||
|
"coopcloud.tech/abra/pkg/log"
|
||||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||||
dConfig "github.com/docker/cli/cli/config"
|
dConfig "github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/cli/cli/context/docker"
|
"github.com/docker/cli/cli/context/docker"
|
||||||
contextStore "github.com/docker/cli/cli/context/store"
|
contextStore "github.com/docker/cli/cli/context/store"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Context = contextStore.Metadata
|
type Context = contextStore.Metadata
|
||||||
|
|
||||||
func CreateContext(contextName string, user string, port string) error {
|
// CreateContext creates a new Docker context.
|
||||||
host := contextName
|
func CreateContext(contextName string) error {
|
||||||
if user != "" {
|
host := fmt.Sprintf("ssh://%s", contextName)
|
||||||
host = fmt.Sprintf("%s@%s", user, host)
|
|
||||||
}
|
|
||||||
if port != "" {
|
|
||||||
host = fmt.Sprintf("%s:%s", host, port)
|
|
||||||
}
|
|
||||||
host = fmt.Sprintf("ssh://%s", host)
|
|
||||||
if err := createContext(contextName, host); err != nil {
|
if err := createContext(contextName, host); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logrus.Debugf("created the '%s' context", contextName)
|
|
||||||
|
log.Debugf("created the %s context", contextName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,8 +69,6 @@ func DeleteContext(name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove any context that might be loaded
|
|
||||||
// TODO: Check if the context we are removing is the active one rather than doing it all the time
|
|
||||||
cfg := dConfig.LoadDefaultConfigFile(nil)
|
cfg := dConfig.LoadDefaultConfigFile(nil)
|
||||||
cfg.CurrentContext = ""
|
cfg.CurrentContext = ""
|
||||||
if err := cfg.Save(); err != nil {
|
if err := cfg.Save(); err != nil {
|
||||||
|
|||||||
@ -1,170 +1,28 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"coopcloud.tech/abra/pkg/web"
|
"github.com/containers/image/docker"
|
||||||
"github.com/docker/distribution/reference"
|
"github.com/containers/image/types"
|
||||||
|
"github.com/distribution/reference"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RawTag struct {
|
// GetRegistryTags retrieves all tags of an image from a container registry.
|
||||||
Layer string
|
func GetRegistryTags(img reference.Named) ([]string, error) {
|
||||||
Name string
|
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"
|
ctx := context.Background()
|
||||||
|
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
|
||||||
func GetRegistryTags(image string) (RawTags, error) {
|
if err != nil {
|
||||||
var tags RawTags
|
|
||||||
|
|
||||||
tagsUrl := fmt.Sprintf(registryURL, image)
|
|
||||||
if err := web.ReadJSON(tagsUrl, &tags); err != nil {
|
|
||||||
return tags, err
|
return tags, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRegv2Token retrieves a registry v2 authentication token.
|
|
||||||
func getRegv2Token(image reference.Named) (string, error) {
|
|
||||||
img := reference.Path(image)
|
|
||||||
authTokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", img)
|
|
||||||
req, err := http.NewRequest("GET", authTokenURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: web.Timeout}
|
|
||||||
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 {
|
|
||||||
Token string
|
|
||||||
Expiry string
|
|
||||||
Issued string
|
|
||||||
}{}
|
|
||||||
|
|
||||||
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(image reference.Named) (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 := http.NewRequest("GET", manifestURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := getRegv2Token(image)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := &http.Client{Timeout: web.Timeout}
|
|
||||||
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"
|
"context"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StoreSecret(secretName, secretValue, server string) error {
|
func StoreSecret(cl *client.Client, secretName, secretValue string) error {
|
||||||
cl, err := New(server)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ann := swarm.Annotations{Name: secretName}
|
ann := swarm.Annotations{Name: secretName}
|
||||||
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
|
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
|
||||||
|
|
||||||
// We don't bother with the secret IDs for now
|
if _, err := cl.SecretCreate(context.Background(), spec); err != nil {
|
||||||
if _, err := cl.SecretCreate(ctx, spec); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user