Compare commits
603 Commits
0.2.1-alph
...
0.4.0-alph
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
17a5f1529a
|
|||
2ba6445daa | |||
edb427a7ae | |||
3dc186e231 | |||
1467ae5007 | |||
2b9395be1a | |||
a539033b55 | |||
63d9703d9d | |||
f9726b6643 | |||
4a0761926c | |||
de7054fd74 | |||
0e0e2db755 | |||
04e24022f5 | |||
c227972c12 | |||
911f22233f | |||
7d8e2d9dd1 | |||
f041083604 | |||
f57ae1e904 | |||
49a87cae2e | |||
f0de18a7f0 | |||
1caef09cd2 | |||
e4e606efb0 | |||
08aca28d9d | |||
f02ea7ca0d | |||
3d3c4b3aae | |||
e37b49201f | |||
ede5a59562 | |||
fc2deda1f6 | |||
c76601c9ce | |||
7f176d8e2f | |||
9b704b002b | |||
ab02c5f0dd | |||
f2b02e39a7 | |||
31f6bd06a5 | |||
bd92c52eed | |||
0486091768 | |||
3b77607f36 | |||
f833ccb864 | |||
7022f42711 | |||
c76bd25c1d | |||
a6b5ac3410 | |||
71225d2099
|
|||
5d59d12d75
|
|||
d56400eea8 | |||
b3496ad286 | |||
066b2b9373 | |||
aec11bda28 | |||
9a513a0700 | |||
9f3ab0de9e | |||
e26afb97af | |||
960e47437c | |||
8e3f90a7f3 | |||
1d7cb0d9b6 | |||
4d2a2d42fb | |||
bdae61ed51 | |||
766e3008f6 | |||
383f857f4a
|
|||
3d46ce6db2 | |||
9e0d77d5c6 | |||
f9e2d24550 | |||
8772217f41 | |||
a7970132c2 | |||
2d091a6b00 | |||
147687d7ce | |||
9a0e12258a | |||
1396f15c78 | |||
2e2560dea7 | |||
c789a70653 | |||
8f55330210 | |||
d54a45bef7 | |||
fdc0246f1d | |||
a394618965 | |||
8cd9f2700f | |||
b72fa28ddb | |||
313e3beb1e | |||
94c7f59113 | |||
5ae06bbd42 | |||
9f9248b987 | |||
2bb4a9c063 | |||
0c8dba0681 | |||
a491332c1c | |||
6a75ffc051 | |||
5261d1a033 | |||
a458a5d9f7 | |||
5ce2419354 | |||
963f8dcc73 | |||
dc04cf5ff7 | |||
80921c9f55 | |||
8b15f2de5b | |||
cdb76e7276 |
18
.drone.yml
18
.drone.yml
@ -3,27 +3,17 @@ kind: pipeline
|
||||
name: coopcloud.tech/abra
|
||||
steps:
|
||||
- name: make check
|
||||
image: golang:1.17
|
||||
image: golang:1.18
|
||||
commands:
|
||||
- make check
|
||||
|
||||
- name: make static
|
||||
image: golang:1.17
|
||||
ignore: true # until we decide we all want this check
|
||||
environment:
|
||||
STATIC_CHECK_URL: honnef.co/go/tools/cmd/staticcheck
|
||||
STATIC_CHECK_VERSION: v0.2.0
|
||||
commands:
|
||||
- go install $STATIC_CHECK_URL@$STATIC_CHECK_VERSION
|
||||
- make static
|
||||
|
||||
- name: make build
|
||||
image: golang:1.17
|
||||
image: golang:1.18
|
||||
commands:
|
||||
- make build
|
||||
|
||||
- name: make test
|
||||
image: golang:1.17
|
||||
image: golang:1.18
|
||||
commands:
|
||||
- make test
|
||||
|
||||
@ -55,7 +45,7 @@ steps:
|
||||
event: tag
|
||||
|
||||
- name: release
|
||||
image: golang:1.17
|
||||
image: golang:1.18
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: goreleaser_gitea_token
|
||||
|
4
.e2e.env.sample
Normal file
4
.e2e.env.sample
Normal file
@ -0,0 +1,4 @@
|
||||
GANDI_TOKEN=...
|
||||
HCLOUD_TOKEN=...
|
||||
REGISTRY_PASSWORD=...
|
||||
REGISTRY_USERNAME=...
|
12
.gitignore
vendored
12
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
abra
|
||||
.vscode/
|
||||
vendor/
|
||||
.envrc
|
||||
dist/
|
||||
*fmtcoverage.html
|
||||
.e2e.env
|
||||
.envrc
|
||||
.vscode/
|
||||
abra
|
||||
dist/
|
||||
tests/integration/.abra/catalogue
|
||||
vendor/
|
||||
|
@ -32,9 +32,7 @@ changelog:
|
||||
filters:
|
||||
exclude:
|
||||
- "^WIP:"
|
||||
- "^chore:"
|
||||
- "^docs:"
|
||||
- "^refactor:"
|
||||
- "^style:"
|
||||
- "^test:"
|
||||
- "^tests:"
|
||||
- "^Revert"
|
||||
|
10
AUTHORS.md
Normal file
10
AUTHORS.md
Normal file
@ -0,0 +1,10 @@
|
||||
# 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
|
||||
|
||||
- 3wordchant
|
||||
- decentral1se
|
||||
- kawaiipunk
|
||||
- knoflook
|
||||
- roxxers
|
14
Makefile
14
Makefile
@ -5,7 +5,7 @@ LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
|
||||
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
|
||||
export GOPRIVATE=coopcloud.tech
|
||||
|
||||
all: run test install build clean format check static
|
||||
all: format check build test
|
||||
|
||||
run:
|
||||
@go run -ldflags=$(LDFLAGS) $(ABRA)
|
||||
@ -28,11 +28,15 @@ format:
|
||||
check:
|
||||
@test -z $$(gofmt -l .) || (echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
|
||||
|
||||
static:
|
||||
@staticcheck $(ABRA)
|
||||
|
||||
test:
|
||||
@go test ./... -cover
|
||||
@go test ./... -cover -v
|
||||
|
||||
loc:
|
||||
@find . -name "*.go" | xargs wc -l
|
||||
|
||||
loc-author:
|
||||
@git ls-files -z | \
|
||||
xargs -0rn 1 -P "$$(nproc)" -I{} sh -c 'git blame -w -M -C -C --line-porcelain -- {} | grep -I --line-buffered "^author "' | \
|
||||
sort -f | \
|
||||
uniq -ic | \
|
||||
sort -n
|
||||
|
103
README.md
103
README.md
@ -7,105 +7,6 @@
|
||||
|
||||
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 applications, deploy them, run backup and restore operations and a whole lot of other things. It is the go-to tool for day-to-day operations when managing a Co-op Cloud instance.
|
||||
`abra` is our flagship client & command-line tool which has been developed specifically in the context of the Co-op Cloud project for the purpose of making day-to-day operations for [operators](https://docs.coopcloud.tech/operators/) and [maintainers](https://docs.coopcloud.tech/maintainers/) as convenient as possible. It is libre software, written in [Go](https://go.dev) and maintained and extended by the community ❤
|
||||
|
||||
## Install
|
||||
|
||||
### Arch-based Linux Distros
|
||||
|
||||
[abra (coming-soon)](https://aur.archlinux.org/packages/abra/) or for the latest version on git [abra-git](https://aur.archlinux.org/packages/abra-git/)
|
||||
|
||||
```sh
|
||||
yay -S abra-git # or abra
|
||||
```
|
||||
|
||||
### Debian-based Linux Distros
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
### Homebrew
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
### Build from source
|
||||
|
||||
```sh
|
||||
git clone https://git.coopcloud.tech/coop-cloud/abra
|
||||
cd abra
|
||||
go env -w GOPRIVATE=coopcloud.tech
|
||||
make install
|
||||
```
|
||||
|
||||
The abra binary will be in `$GOPATH/bin`.
|
||||
|
||||
## Autocompletion
|
||||
|
||||
**bash**
|
||||
|
||||
Copy `scripts/autocomplete/bash` into `/etc/bash_completion.d/` and rename
|
||||
it to abra.
|
||||
|
||||
```
|
||||
sudo cp scripts/autocomplete/bash /etc/bash_completion.d/abra
|
||||
source /etc/bash_completion.d/abra
|
||||
```
|
||||
|
||||
In development, you can source the script in your git checkout, just make sure
|
||||
to set `PROG=abra`, otherwise it'll add completion to the wrong command:
|
||||
|
||||
```
|
||||
PROG=abra source /path/to/abra/scripts/autocomplete/bash
|
||||
```
|
||||
|
||||
**(fi)zsh**
|
||||
|
||||
(fi)zsh doesn't have an autocompletion folder by default but you can create one, then copy `scripts/autocomplete/zsh` into it and add a couple lines to your `~/.zshrc` or `~/.fizsh/.fizshrc`
|
||||
|
||||
```
|
||||
sudo mkdir /etc/zsh/completion.d/
|
||||
sudo cp scripts/autocomplete/zsh /etc/zsh/completion.d/abra
|
||||
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
|
||||
```
|
||||
|
||||
(replace .zshrc with ~/.fizsh/.fizshrc if you use fizsh)
|
||||
|
||||
## Hacking
|
||||
|
||||
Install direnv, 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
|
||||
|
||||
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 0.3.1-alpha'`)
|
||||
- Make a new tag (e.g. `git tag 0.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 version`)
|
||||
|
||||
## Fork maintenance
|
||||
|
||||
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.
|
||||
Please see [docs.coopcloud.tech/abra/](https://docs.coopcloud.tech/abra/) for help on install, upgrade, hacking, troubleshooting & more!
|
||||
|
@ -1,28 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// AppCommand defines the `abra app` command and ets subcommands
|
||||
var AppCommand = &cli.Command{
|
||||
var AppCommand = cli.Command{
|
||||
Name: "app",
|
||||
Usage: "Manage deployed 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{
|
||||
Usage: "Manage apps",
|
||||
ArgsUsage: "<domain>",
|
||||
Description: "This command provides functionality for managing the life cycle of your apps",
|
||||
Subcommands: []cli.Command{
|
||||
appNewCommand,
|
||||
appConfigCommand,
|
||||
appRestartCommand,
|
||||
appDeployCommand,
|
||||
appUpgradeCommand,
|
||||
appUndeployCommand,
|
||||
appBackupCommand,
|
||||
appRestoreCommand,
|
||||
appRemoveCommand,
|
||||
appCheckCommand,
|
||||
appListCommand,
|
||||
@ -34,5 +28,7 @@ to scaling apps up and spinning them down.
|
||||
appSecretCommand,
|
||||
appVolumeCommand,
|
||||
appVersionCommand,
|
||||
appErrorsCommand,
|
||||
appCmdCommand,
|
||||
},
|
||||
}
|
||||
|
@ -1,87 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var backupAllServices bool
|
||||
var backupAllServicesFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Value: false,
|
||||
Destination: &backupAllServices,
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Backup all services",
|
||||
}
|
||||
|
||||
var appBackupCommand = &cli.Command{
|
||||
Name: "backup",
|
||||
Usage: "Backup an app",
|
||||
Aliases: []string{"b"},
|
||||
Flags: []cli.Flag{backupAllServicesFlag},
|
||||
ArgsUsage: "<service>",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Get(1) != "" && backupAllServices {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together"))
|
||||
}
|
||||
|
||||
abraSh := path.Join(config.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) {
|
||||
appNames, err := config.GetAppNames()
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
}
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
}
|
||||
for _, a := range appNames {
|
||||
fmt.Println(a)
|
||||
}
|
||||
},
|
||||
}
|
@ -1,29 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appCheckCommand = &cli.Command{
|
||||
var appCheckCommand = cli.Command{
|
||||
Name: "check",
|
||||
Aliases: []string{"chk"},
|
||||
Usage: "Check if app is configured correctly",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<service>",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
envSamplePath := path.Join(config.ABRA_DIR, "apps", app.Type, ".env.sample")
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, app.Recipe, ".env.sample")
|
||||
if _, err := os.Stat(envSamplePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Fatalf("'%s' does not exist?", envSamplePath)
|
||||
logrus.Fatalf("%s does not exist?", envSamplePath)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -45,20 +49,9 @@ var appCheckCommand = &cli.Command{
|
||||
logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars)
|
||||
}
|
||||
|
||||
logrus.Infof("all necessary environment variables defined for '%s'", app.Name)
|
||||
logrus.Infof("all necessary environment variables defined for %s", app.Name)
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
233
cli/app/cmd.go
Normal file
233
cli/app/cmd.go
Normal file
@ -0,0 +1,233 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var localCmd bool
|
||||
var localCmdFlag = &cli.BoolFlag{
|
||||
Name: "local, l",
|
||||
Usage: "Run command locally",
|
||||
Destination: &localCmd,
|
||||
}
|
||||
|
||||
var remoteUser string
|
||||
var remoteUserFlag = &cli.StringFlag{
|
||||
Name: "user, u",
|
||||
Value: "",
|
||||
Usage: "User to run command within a service context",
|
||||
Destination: &remoteUser,
|
||||
}
|
||||
|
||||
var appCmdCommand = cli.Command{
|
||||
Name: "command",
|
||||
Aliases: []string{"cmd"},
|
||||
Usage: "Run app commands",
|
||||
Description: `
|
||||
This command runs app specific commands.
|
||||
|
||||
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". Arguments can be passed into these functions
|
||||
using the "-- <args>" syntax.
|
||||
|
||||
Example:
|
||||
|
||||
abra app cmd example.com app create_user -- me@example.com
|
||||
`,
|
||||
ArgsUsage: "<domain> [<service>] <command>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
localCmdFlag,
|
||||
remoteUserFlag,
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if localCmd && remoteUser != "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use --local & <user> together"))
|
||||
}
|
||||
|
||||
abraSh := path.Join(config.RECIPES_DIR, app.Recipe, "abra.sh")
|
||||
if _, err := os.Stat(abraSh); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist for %s?", abraSh, app.Name)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var parsedCmdArgs string
|
||||
var cmdArgsIdx int
|
||||
var hasCmdArgs bool
|
||||
for idx, arg := range c.Args() {
|
||||
if arg == "--" {
|
||||
cmdArgsIdx = idx
|
||||
hasCmdArgs = true
|
||||
}
|
||||
|
||||
if hasCmdArgs && idx > cmdArgsIdx {
|
||||
parsedCmdArgs += fmt.Sprintf("%s ", c.Args().Get(idx))
|
||||
}
|
||||
}
|
||||
|
||||
if localCmd {
|
||||
cmdName := c.Args().Get(1)
|
||||
if err := ensureCommand(abraSh, app.Recipe, cmdName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("--local detected, running %s on local work station", cmdName)
|
||||
|
||||
var sourceAndExec string
|
||||
if hasCmdArgs {
|
||||
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
|
||||
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; . %s; %s %s", app.StackName(), abraSh, cmdName, parsedCmdArgs)
|
||||
} else {
|
||||
logrus.Debug("did not detect any command arguments")
|
||||
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; . %s; %s", app.StackName(), abraSh, cmdName)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", sourceAndExec)
|
||||
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
targetServiceName := c.Args().Get(1)
|
||||
|
||||
cmdName := c.Args().Get(2)
|
||||
if err := ensureCommand(abraSh, app.Recipe, cmdName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
serviceNames, err := config.GetAppServiceNames(app.Name)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
matchingServiceName := false
|
||||
for _, serviceName := range serviceNames {
|
||||
if serviceName == targetServiceName {
|
||||
matchingServiceName = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matchingServiceName {
|
||||
logrus.Fatalf("no service %s for %s?", targetServiceName, app.Name)
|
||||
}
|
||||
|
||||
logrus.Debugf("running command %s within the context of %s_%s", cmdName, app.StackName(), targetServiceName)
|
||||
|
||||
if hasCmdArgs {
|
||||
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
|
||||
} else {
|
||||
logrus.Debug("did not detect any command arguments")
|
||||
}
|
||||
|
||||
if err := runCmdRemote(app, abraSh, targetServiceName, cmdName, parsedCmdArgs); err != nil {
|
||||
logrus.Fatal(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
|
||||
}
|
||||
|
||||
func runCmdRemote(app config.App, abraSh, serviceName, cmdName, cmdArgs string) error {
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
|
||||
|
||||
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.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 := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
||||
if err := cl.CopyToContainer(context.Background(), targetContainer.ID, "/tmp", content, copyOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cmd []string
|
||||
if cmdArgs != "" {
|
||||
cmd = []string{"/bin/sh", "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; . /tmp/abra.sh; %s %s", serviceName, app.StackName(), cmdName, cmdArgs)}
|
||||
} else {
|
||||
cmd = []string{"/bin/sh", "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; . /tmp/abra.sh; %s", serviceName, app.StackName(), cmdName)}
|
||||
}
|
||||
|
||||
logrus.Debugf("running command: %s", strings.Join(cmd, " "))
|
||||
|
||||
execCreateOpts := types.ExecConfig{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
Cmd: cmd,
|
||||
Detach: false,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
if remoteUser != "" {
|
||||
logrus.Debugf("running command with user %s", remoteUser)
|
||||
execCreateOpts.User = remoteUser
|
||||
}
|
||||
|
||||
// FIXME: avoid instantiating a new CLI
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,23 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appConfigCommand = &cli.Command{
|
||||
var appConfigCommand = cli.Command{
|
||||
Name: "config",
|
||||
Aliases: []string{"c"},
|
||||
Aliases: []string{"cfg"},
|
||||
Usage: "Edit app config",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
appName := c.Args().First()
|
||||
|
||||
if appName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
||||
}
|
||||
|
||||
files, err := config.LoadAppFiles("")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
appFile, exists := files[appName]
|
||||
if !exists {
|
||||
logrus.Fatalf("cannot find app with name %s", appName)
|
||||
}
|
||||
|
||||
ed, ok := os.LookupEnv("EDITOR")
|
||||
if !ok {
|
||||
@ -30,7 +50,7 @@ var appConfigCommand = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(ed, app.Path)
|
||||
cmd := exec.Command(ed, appFile.Path)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@ -40,16 +60,5 @@ var appConfigCommand = &cli.Command{
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
@ -1,26 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appCpCommand = &cli.Command{
|
||||
var appCpCommand = cli.Command{
|
||||
Name: "cp",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<src> <dst>",
|
||||
ArgsUsage: "<domain> <src> <dst>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "Copy files to/from a running app service",
|
||||
Description: `
|
||||
This command supports copying files to and from any app service file system.
|
||||
|
||||
If you want to copy a myfile.txt to the root of the app service:
|
||||
|
||||
abra app cp <domain> myfile.txt app:/
|
||||
|
||||
And if you want to copy that file back to your current working directory locally:
|
||||
|
||||
abra app cp <domain> app:/myfile.txt .
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
@ -64,38 +83,46 @@ var appCpCommand = &cli.Command{
|
||||
logrus.Debugf("assuming transfer is going TO the container")
|
||||
}
|
||||
|
||||
appFiles, err := config.LoadAppFiles("")
|
||||
if !isToContainer {
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
logrus.Fatalf("%s does not exist locally?", dstPath)
|
||||
}
|
||||
}
|
||||
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
appEnv, err := config.GetApp(appFiles, app.Name)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
func configureAndCp(
|
||||
c *cli.Context,
|
||||
app config.App,
|
||||
srcPath string,
|
||||
dstPath string,
|
||||
service string,
|
||||
isToContainer bool) error {
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
|
||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service))
|
||||
|
||||
container, err := container.GetContainer(context.Background(), cl, filters, internal.NoInput)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(containers) != 1 {
|
||||
logrus.Fatalf("expected 1 container but got %v", len(containers))
|
||||
}
|
||||
container := containers[0]
|
||||
|
||||
logrus.Debugf("retrieved '%s' as target container on '%s'", formatter.ShortenID(container.ID), app.Server)
|
||||
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)
|
||||
logrus.Fatalf("%s does not exist?", srcPath)
|
||||
}
|
||||
|
||||
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
|
||||
@ -105,11 +132,11 @@ var appCpCommand = &cli.Command{
|
||||
}
|
||||
|
||||
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
|
||||
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
|
||||
if err := cl.CopyToContainer(context.Background(), container.ID, dstPath, content, copyOpts); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
|
||||
content, _, err := cl.CopyFromContainer(context.Background(), container.ID, srcPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -119,6 +146,6 @@ var appCpCommand = &cli.Command{
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
@ -1,33 +1,29 @@
|
||||
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"
|
||||
stack "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appDeployCommand = &cli.Command{
|
||||
var appDeployCommand = cli.Command{
|
||||
Name: "deploy",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Deploy an app",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.NoDomainChecksFlag,
|
||||
internal.DontWaitConvergeFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
This command deploys a new instance of an app. It does not support changing the
|
||||
version of an existing deployed app, for this you need to look at the "abra app
|
||||
upgrade <app>" command.
|
||||
This command deploys an app. It does not support incrementing the version of a
|
||||
deployed app, for this you need to look at the "abra app upgrade <domain>"
|
||||
command.
|
||||
|
||||
You may pass "--force" to re-deploy the same version again. This can be useful
|
||||
if the container runtime has gotten into a weird state.
|
||||
@ -36,181 +32,6 @@ Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
recipes.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
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 isDeployed {
|
||||
if internal.Force {
|
||||
logrus.Warnf("'%s' already deployed but continuing (--force)", stackName)
|
||||
} else if internal.Chaos {
|
||||
logrus.Warnf("'%s' already deployed but continuing (--chaos)", stackName)
|
||||
} else {
|
||||
logrus.Fatalf("'%s' is already deployed", stackName)
|
||||
}
|
||||
}
|
||||
|
||||
version := deployedVersion
|
||||
if version == "" && !internal.Chaos {
|
||||
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 == "" && !internal.Chaos {
|
||||
logrus.Debugf("choosing '%s' as version to deploy", version)
|
||||
if err := recipe.EnsureVersion(app.Type, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.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
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// DeployOverview shows a deployment overview
|
||||
func DeployOverview(app config.App, version, message string) error {
|
||||
tableCol := []string{"server", "compose", "domain", "stack", "version"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
}
|
||||
|
||||
server := app.Server
|
||||
if app.Server == "default" {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewVersionOverview shows an upgrade or downgrade overview
|
||||
func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
|
||||
tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
}
|
||||
|
||||
server := app.Server
|
||||
if app.Server == "default" {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with deployment?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
return nil
|
||||
Action: internal.DeployAction,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
143
cli/app/errors.go
Normal file
143
cli/app/errors.go
Normal file
@ -0,0 +1,143 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appErrorsCommand = cli.Command{
|
||||
Name: "errors",
|
||||
Usage: "List errors for a deployed app",
|
||||
ArgsUsage: "<domain>",
|
||||
Description: `
|
||||
This command lists errors for a deployed app.
|
||||
|
||||
This is a best-effort implementation and an attempt to gather a number of tips
|
||||
& tricks for finding errors together into one convenient command. When an app
|
||||
is failing to deploy or having issues, it could be a lot of things.
|
||||
|
||||
This command currently takes into account:
|
||||
|
||||
Is the service deployed?
|
||||
Is the service killed by an OOM error?
|
||||
Is the service reporting an error (like in "ps --no-trunc" output)
|
||||
Is the service healthcheck failing? what are the healthcheck logs?
|
||||
|
||||
Got any more ideas? Please let us know:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
|
||||
|
||||
This command is best accompanied by "abra app logs <domain>" which may reveal
|
||||
further information which can help you debug the cause of an app failure via
|
||||
the logs.
|
||||
`,
|
||||
Aliases: []string{"e"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.WatchFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if !internal.Watch {
|
||||
if err := checkErrors(c, cl, app); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
if err := checkErrors(c, cl, app); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
|
||||
recipe, err := recipe.Get(app.Recipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
|
||||
|
||||
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
logrus.Warnf("%s is not up, something seems wrong", service.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
container := containers[0]
|
||||
containerState, err := cl.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if containerState.State.OOMKilled {
|
||||
logrus.Warnf("%s has been killed due to an out of memory error", service.Name)
|
||||
}
|
||||
|
||||
if containerState.State.Error != "" {
|
||||
logrus.Warnf("%s reports this error: %s", service.Name, containerState.State.Error)
|
||||
}
|
||||
|
||||
if containerState.State.Health != nil {
|
||||
if containerState.State.Health.Status != "healthy" {
|
||||
logrus.Warnf("%s healthcheck status is %s", service.Name, containerState.State.Health.Status)
|
||||
logrus.Warnf("%s healthcheck has failed %s times", service.Name, strconv.Itoa(containerState.State.Health.FailingStreak))
|
||||
for _, log := range containerState.State.Health.Log {
|
||||
logrus.Warnf("%s healthcheck logs: %s", service.Name, strings.TrimSpace(log.Output))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getServiceName(names []string) string {
|
||||
containerName := strings.Join(names, " ")
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
return strings.Split(trimmed, ".")[0]
|
||||
}
|
211
cli/app/list.go
211
cli/app/list.go
@ -5,43 +5,61 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var status bool
|
||||
var statusFlag = &cli.BoolFlag{
|
||||
Name: "status",
|
||||
Aliases: []string{"S"},
|
||||
Value: false,
|
||||
Name: "status, S",
|
||||
Usage: "Show app deployment status",
|
||||
Destination: &status,
|
||||
}
|
||||
|
||||
var appType string
|
||||
var typeFlag = &cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
var appRecipe string
|
||||
var recipeFlag = &cli.StringFlag{
|
||||
Name: "recipe, r",
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific type",
|
||||
Destination: &appType,
|
||||
Usage: "Show apps of a specific recipe",
|
||||
Destination: &appRecipe,
|
||||
}
|
||||
|
||||
var listAppServer string
|
||||
var listAppServerFlag = &cli.StringFlag{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Name: "server, s",
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific server",
|
||||
Destination: &listAppServer,
|
||||
}
|
||||
|
||||
var appListCommand = &cli.Command{
|
||||
type appStatus struct {
|
||||
server string
|
||||
recipe string
|
||||
appName string
|
||||
domain string
|
||||
status string
|
||||
version string
|
||||
upgrade string
|
||||
}
|
||||
|
||||
type serverStatus struct {
|
||||
apps []appStatus
|
||||
appCount int
|
||||
versionCount int
|
||||
unversionedCount int
|
||||
latestCount int
|
||||
upgradeCount int
|
||||
}
|
||||
|
||||
var appListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List all managed apps",
|
||||
Description: `
|
||||
This command looks at your local file system listing of apps and servers (e.g.
|
||||
@ -51,12 +69,13 @@ 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"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
statusFlag,
|
||||
listAppServerFlag,
|
||||
typeFlag,
|
||||
recipeFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
appFiles, err := config.LoadAppFiles(listAppServer)
|
||||
if err != nil {
|
||||
@ -67,54 +86,81 @@ can take some time.
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
sort.Sort(config.ByServerAndType(apps))
|
||||
|
||||
sort.Sort(config.ByServerAndRecipe(apps))
|
||||
|
||||
statuses := make(map[string]map[string]string)
|
||||
tableCol := []string{"Server", "Type", "Domain"}
|
||||
var catl recipe.RecipeCatalogue
|
||||
if status {
|
||||
tableCol = append(tableCol, "Status", "Version", "Updates")
|
||||
alreadySeen := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
if _, ok := alreadySeen[app.Server]; !ok {
|
||||
if err := ssh.EnsureHostKey(app.Server); err != nil {
|
||||
logrus.Fatal(fmt.Sprintf(internal.SSHFailMsg, app.Server))
|
||||
}
|
||||
alreadySeen[app.Server] = true
|
||||
}
|
||||
}
|
||||
|
||||
statuses, err = config.GetAppStatuses(appFiles)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
catl, err = recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
table.SetAutoMergeCellsByColumnIndex([]int{0})
|
||||
|
||||
var (
|
||||
versionedAppsCount int
|
||||
unversionedAppsCount int
|
||||
onLatestCount int
|
||||
canUpgradeCount int
|
||||
)
|
||||
|
||||
var totalServersCount int
|
||||
var totalAppsCount int
|
||||
allStats := make(map[string]serverStatus)
|
||||
for _, app := range apps {
|
||||
var tableRow []string
|
||||
if app.Type == appType || appType == "" {
|
||||
// If type flag is set, check for it, if not, Type == ""
|
||||
tableRow = []string{app.Server, app.Type, app.Domain}
|
||||
var stats serverStatus
|
||||
var ok bool
|
||||
if stats, ok = allStats[app.Server]; !ok {
|
||||
stats = serverStatus{}
|
||||
if appRecipe == "" {
|
||||
// count server, no filtering
|
||||
totalServersCount++
|
||||
}
|
||||
}
|
||||
|
||||
if app.Recipe == appRecipe || appRecipe == "" {
|
||||
if appRecipe != "" {
|
||||
// only count server if matches filter
|
||||
totalServersCount++
|
||||
}
|
||||
|
||||
appStats := appStatus{}
|
||||
stats.appCount++
|
||||
totalAppsCount++
|
||||
|
||||
if status {
|
||||
stackName := app.StackName()
|
||||
status := "unknown"
|
||||
version := "unknown"
|
||||
if statusMeta, ok := statuses[stackName]; ok {
|
||||
if statusMeta, ok := statuses[app.StackName()]; ok {
|
||||
if currentVersion, exists := statusMeta["version"]; exists {
|
||||
if currentVersion != "" {
|
||||
version = currentVersion
|
||||
}
|
||||
}
|
||||
if statusMeta["status"] != "" {
|
||||
status = statusMeta["status"]
|
||||
}
|
||||
tableRow = append(tableRow, status, version)
|
||||
versionedAppsCount++
|
||||
stats.versionCount++
|
||||
} else {
|
||||
tableRow = append(tableRow, status, version)
|
||||
unversionedAppsCount++
|
||||
stats.unversionedCount++
|
||||
}
|
||||
|
||||
appStats.status = status
|
||||
appStats.version = version
|
||||
|
||||
var newUpdates []string
|
||||
if version != "unknown" {
|
||||
updates, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
||||
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -138,36 +184,81 @@ can take some time.
|
||||
|
||||
if len(newUpdates) == 0 {
|
||||
if version == "unknown" {
|
||||
tableRow = append(tableRow, "unknown")
|
||||
appStats.upgrade = "unknown"
|
||||
} else {
|
||||
tableRow = append(tableRow, "on latest")
|
||||
onLatestCount++
|
||||
appStats.upgrade = "latest"
|
||||
stats.latestCount++
|
||||
}
|
||||
} else {
|
||||
// FIXME: jeezus golang why do you not have a list reverse function
|
||||
for i, j := 0, len(newUpdates)-1; i < j; i, j = i+1, j-1 {
|
||||
newUpdates[i], newUpdates[j] = newUpdates[j], newUpdates[i]
|
||||
}
|
||||
tableRow = append(tableRow, strings.Join(newUpdates, "\n"))
|
||||
canUpgradeCount++
|
||||
newUpdates = internal.ReverseStringList(newUpdates)
|
||||
appStats.upgrade = strings.Join(newUpdates, "\n")
|
||||
stats.upgradeCount++
|
||||
}
|
||||
}
|
||||
|
||||
appStats.server = app.Server
|
||||
appStats.recipe = app.Recipe
|
||||
appStats.appName = app.Name
|
||||
appStats.domain = app.Domain
|
||||
|
||||
stats.apps = append(stats.apps, appStats)
|
||||
}
|
||||
|
||||
allStats[app.Server] = stats
|
||||
}
|
||||
|
||||
alreadySeen := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
if _, ok := alreadySeen[app.Server]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
serverStat := allStats[app.Server]
|
||||
|
||||
tableCol := []string{"recipe", "domain"}
|
||||
if status {
|
||||
tableCol = append(tableCol, []string{"status", "version", "upgrade"}...)
|
||||
}
|
||||
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, appStat := range serverStat.apps {
|
||||
tableRow := []string{appStat.recipe, appStat.domain}
|
||||
if status {
|
||||
tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...)
|
||||
}
|
||||
table.Append(tableRow)
|
||||
}
|
||||
|
||||
stats := fmt.Sprintf(
|
||||
"Total apps: %v | Versioned: %v | Unversioned: %v | On latest: %v | Can upgrade: %v",
|
||||
len(apps),
|
||||
versionedAppsCount,
|
||||
unversionedAppsCount,
|
||||
onLatestCount,
|
||||
canUpgradeCount,
|
||||
)
|
||||
|
||||
table.SetCaption(true, stats)
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
|
||||
if status {
|
||||
fmt.Println(fmt.Sprintf(
|
||||
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
|
||||
app.Server,
|
||||
serverStat.appCount,
|
||||
serverStat.versionCount,
|
||||
serverStat.unversionedCount,
|
||||
serverStat.latestCount,
|
||||
serverStat.upgradeCount,
|
||||
))
|
||||
} else {
|
||||
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(allStats) > 1 && table.NumLines() > 0 {
|
||||
fmt.Println() // newline separator for multiple servers
|
||||
}
|
||||
|
||||
alreadySeen[app.Server] = true
|
||||
}
|
||||
|
||||
if len(allStats) > 1 {
|
||||
fmt.Println(fmt.Sprintf("total servers: %v | total apps: %v ", totalServersCount, totalAppsCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
101
cli/app/logs.go
101
cli/app/logs.go
@ -1,27 +1,42 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var logOpts = types.ContainerLogsOptions{
|
||||
Details: false,
|
||||
Follow: true,
|
||||
ShowStderr: true,
|
||||
ShowStdout: true,
|
||||
Tail: "20",
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
// stackLogs lists logs for all stack services
|
||||
func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", stackName)
|
||||
func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) {
|
||||
filters, err := app.Filters(true, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
serviceOpts := types.ServiceListOptions{Filters: filters}
|
||||
services, err := client.ServiceList(c.Context, serviceOpts)
|
||||
services, err := client.ServiceList(context.Background(), serviceOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -30,19 +45,14 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
|
||||
for _, service := range services {
|
||||
wg.Add(1)
|
||||
go func(s string) {
|
||||
logOpts := types.ContainerLogsOptions{
|
||||
Details: true,
|
||||
Follow: true,
|
||||
ShowStderr: true,
|
||||
ShowStdout: true,
|
||||
Tail: "20",
|
||||
Timestamps: true,
|
||||
if internal.StdErrOnly {
|
||||
logOpts.ShowStdout = false
|
||||
}
|
||||
logs, err := client.ServiceLogs(c.Context, s, logOpts)
|
||||
|
||||
logs, err := client.ServiceLogs(context.Background(), s, logOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
// defer after err check as any err returns a nil io.ReadCloser
|
||||
defer logs.Close()
|
||||
|
||||
_, err = io.Copy(os.Stdout, logs)
|
||||
@ -51,15 +61,23 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
|
||||
}
|
||||
}(service.ID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
var appLogsCommand = &cli.Command{
|
||||
var appLogsCommand = cli.Command{
|
||||
Name: "logs",
|
||||
Aliases: []string{"l"},
|
||||
ArgsUsage: "[<service>]",
|
||||
ArgsUsage: "<domain> [<service>]",
|
||||
Usage: "Tail app logs",
|
||||
Flags: []cli.Flag{
|
||||
internal.StdErrOnlyFlag,
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
@ -70,36 +88,36 @@ var appLogsCommand = &cli.Command{
|
||||
|
||||
serviceName := c.Args().Get(1)
|
||||
if serviceName == "" {
|
||||
logrus.Debug("tailing logs for all app services")
|
||||
stackLogs(c, app.StackName(), cl)
|
||||
logrus.Debugf("tailing logs for all %s services", app.Recipe)
|
||||
stackLogs(c, app, cl)
|
||||
} else {
|
||||
logrus.Debugf("tailing logs for %s", serviceName)
|
||||
if err := tailServiceLogs(c, cl, app, serviceName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
logrus.Debugf("tailing logs for '%s'", serviceName)
|
||||
|
||||
service := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error {
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", service)
|
||||
serviceOpts := types.ServiceListOptions{Filters: filters}
|
||||
services, err := cl.ServiceList(c.Context, serviceOpts)
|
||||
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
|
||||
|
||||
chosenService, err := service.GetService(context.Background(), cl, filters, internal.NoInput)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if len(services) != 1 {
|
||||
logrus.Fatalf("expected 1 service but got %v", len(services))
|
||||
}
|
||||
|
||||
logOpts := types.ContainerLogsOptions{
|
||||
Details: true,
|
||||
Follow: true,
|
||||
ShowStderr: true,
|
||||
ShowStdout: true,
|
||||
Tail: "20",
|
||||
Timestamps: true,
|
||||
if internal.StdErrOnly {
|
||||
logOpts.ShowStdout = false
|
||||
}
|
||||
logs, err := cl.ServiceLogs(c.Context, services[0].ID, logOpts)
|
||||
|
||||
logs, err := cl.ServiceLogs(context.Background(), chosenService.ID, logOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
// defer after err check as any err returns a nil io.ReadCloser
|
||||
defer logs.Close()
|
||||
|
||||
_, err = io.Copy(os.Stdout, logs)
|
||||
@ -108,17 +126,4 @@ var appLogsCommand = &cli.Command{
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
214
cli/app/new.go
214
cli/app/new.go
@ -1,54 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
type secrets 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,
|
||||
}
|
||||
|
||||
var appNewDescription = `
|
||||
This command takes a recipe and uses it to create a new app. This new app
|
||||
configuration is stored in your ~/.abra directory under the appropriate server.
|
||||
|
||||
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)
|
||||
by running "abra recipe ls".
|
||||
@ -63,172 +26,21 @@ pass store (see passwordstore.org for more). The pass command must be available
|
||||
on your $PATH.
|
||||
`
|
||||
|
||||
var appNewCommand = &cli.Command{
|
||||
var appNewCommand = cli.Command{
|
||||
Name: "new",
|
||||
Usage: "Create a new app",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Create a new app",
|
||||
Description: appNewDescription,
|
||||
Flags: []cli.Flag{
|
||||
newAppServerFlag,
|
||||
domainFlag,
|
||||
newAppNameFlag,
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.NewAppServerFlag,
|
||||
internal.DomainFlag,
|
||||
internal.PassFlag,
|
||||
internal.SecretsFlag,
|
||||
},
|
||||
ArgsUsage: "<recipe>",
|
||||
Action: action,
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
|
||||
func ensureDomainFlag() error {
|
||||
if domain == "" {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify app domain",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &domain); 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 newAppServer == "" {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select app server:",
|
||||
Options: servers,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &newAppServer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
|
||||
func ensureAppNameFlag() error {
|
||||
if newAppName == "" {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify app name:",
|
||||
Default: config.SanitiseAppName(domain),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &newAppName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSecrets creates all secrets for a new app.
|
||||
func createSecrets(sanitisedAppName string) (secrets, 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 internal.Pass {
|
||||
for secretName := range secrets {
|
||||
secretValue := secrets[secretName]
|
||||
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, newAppServer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// action is the main command-line action for this package
|
||||
func action(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
|
||||
if err := config.EnsureAbraDirExists(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureServerFlag(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDomainFlag(); 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 internal.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("New '%s' created! Here is your new app 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
|
||||
Before: internal.SubCommandBefore,
|
||||
ArgsUsage: "[<recipe>]",
|
||||
Action: internal.NewAction,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
110
cli/app/ps.go
110
cli/app/ps.go
@ -1,65 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/docker/cli/cli/command/formatter"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/service"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/buger/goterm"
|
||||
dockerFormatter "github.com/docker/cli/cli/command/formatter"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var watch bool
|
||||
var watchFlag = &cli.BoolFlag{
|
||||
Name: "watch",
|
||||
Aliases: []string{"w"},
|
||||
Value: false,
|
||||
Usage: "Watch status by polling repeatedly",
|
||||
Destination: &watch,
|
||||
}
|
||||
|
||||
var appPsCommand = &cli.Command{
|
||||
var appPsCommand = cli.Command{
|
||||
Name: "ps",
|
||||
Usage: "Check app status",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Check app status",
|
||||
ArgsUsage: "<domain>",
|
||||
Description: "This command shows a more detailed status output of a specific deployed app.",
|
||||
Flags: []cli.Flag{
|
||||
watchFlag,
|
||||
internal.WatchFlag,
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
if !watch {
|
||||
showPSOutput(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: how do we make this update in-place in an x-platform way?
|
||||
for {
|
||||
showPSOutput(c)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// showPSOutput renders ps output.
|
||||
func showPSOutput(c *cli.Context) {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
@ -67,16 +40,44 @@ func showPSOutput(c *cli.Context) {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
|
||||
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
|
||||
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"image", "created", "status", "ports", "names"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if !internal.Watch {
|
||||
showPSOutput(c, app, cl)
|
||||
return nil
|
||||
}
|
||||
|
||||
goterm.Clear()
|
||||
for {
|
||||
goterm.MoveCursor(1, 1)
|
||||
showPSOutput(c, app, cl)
|
||||
goterm.Flush()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// showPSOutput renders ps output.
|
||||
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
|
||||
filters, err := app.Filters(true, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"service name", "image", "created", "status", "state", "ports"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, container := range containers {
|
||||
var containerNames []string
|
||||
@ -86,11 +87,12 @@ func showPSOutput(c *cli.Context) {
|
||||
}
|
||||
|
||||
tableRow := []string{
|
||||
abraFormatter.RemoveSha(container.Image),
|
||||
abraFormatter.HumanDuration(container.Created),
|
||||
service.ContainerToServiceName(container.Names, app.StackName()),
|
||||
formatter.RemoveSha(container.Image),
|
||||
formatter.HumanDuration(container.Created),
|
||||
container.Status,
|
||||
formatter.DisplayablePorts(container.Ports),
|
||||
strings.Join(containerNames, "\n"),
|
||||
container.State,
|
||||
dockerFormatter.DisplayablePorts(container.Ports),
|
||||
}
|
||||
table.Append(tableRow)
|
||||
}
|
||||
|
@ -1,17 +1,18 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// Volumes stores the variable from VolumesFlag
|
||||
@ -19,58 +20,57 @@ var Volumes bool
|
||||
|
||||
// VolumesFlag is used to specify if volumes should be deleted when deleting an app
|
||||
var VolumesFlag = &cli.BoolFlag{
|
||||
Name: "volumes",
|
||||
Value: false,
|
||||
Name: "volumes, V",
|
||||
Destination: &Volumes,
|
||||
}
|
||||
|
||||
var appRemoveCommand = &cli.Command{
|
||||
var appRemoveCommand = cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove an already undeployed app",
|
||||
Aliases: []string{"rm"},
|
||||
ArgsUsage: "<domain>",
|
||||
Usage: "Remove an already undeployed app",
|
||||
Flags: []cli.Flag{
|
||||
VolumesFlag,
|
||||
internal.ForceFlag,
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if !internal.Force {
|
||||
if !internal.Force && !internal.NoInput {
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("about to delete %s, are you sure?", app.Name),
|
||||
Message: fmt.Sprintf("about to remove %s, are you sure?", app.Name),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if !response {
|
||||
logrus.Fatal("user aborted app removal")
|
||||
logrus.Fatal("aborting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
appFiles, err := config.LoadAppFiles("")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if !internal.Force {
|
||||
// FIXME: only query for app we are interested in, not all of them!
|
||||
statuses, err := config.GetAppStatuses(appFiles)
|
||||
|
||||
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
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)
|
||||
}
|
||||
if isDeployed {
|
||||
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
|
||||
}
|
||||
|
||||
fs := filters.NewArgs()
|
||||
fs.Add("name", app.Name)
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: fs})
|
||||
fs, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: fs})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -85,9 +85,12 @@ var appRemoveCommand = &cli.Command{
|
||||
|
||||
if len(secrets) > 0 {
|
||||
var secretNamesToRemove []string
|
||||
if !internal.Force {
|
||||
|
||||
if !internal.Force && !internal.NoInput {
|
||||
secretsPrompt := &survey.MultiSelect{
|
||||
Message: "which secrets do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: secretNames,
|
||||
Default: secretNames,
|
||||
}
|
||||
@ -96,8 +99,12 @@ var appRemoveCommand = &cli.Command{
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Force || internal.NoInput {
|
||||
secretNamesToRemove = secretNames
|
||||
}
|
||||
|
||||
for _, name := range secretNamesToRemove {
|
||||
err := cl.SecretRemove(c.Context, secrets[name])
|
||||
err := cl.SecretRemove(context.Background(), secrets[name])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -107,7 +114,12 @@ var appRemoveCommand = &cli.Command{
|
||||
logrus.Info("no secrets to remove")
|
||||
}
|
||||
|
||||
volumeListOKBody, err := cl.VolumeList(c.Context, fs)
|
||||
fs, err = app.Filters(false, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
volumeListOKBody, err := cl.VolumeList(context.Background(), fs)
|
||||
volumeList := volumeListOKBody.Volumes
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
@ -121,9 +133,11 @@ var appRemoveCommand = &cli.Command{
|
||||
if len(vols) > 0 {
|
||||
if Volumes {
|
||||
var removeVols []string
|
||||
if !internal.Force {
|
||||
if !internal.Force && !internal.NoInput {
|
||||
volumesPrompt := &survey.MultiSelect{
|
||||
Message: "which volumes do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: vols,
|
||||
Default: vols,
|
||||
}
|
||||
@ -131,8 +145,9 @@ var appRemoveCommand = &cli.Command{
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, vol := range removeVols {
|
||||
err := cl.VolumeRemove(c.Context, vol, internal.Force) // last argument is for force removing
|
||||
err := cl.VolumeRemove(context.Background(), vol, internal.Force) // last argument is for force removing
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -142,8 +157,10 @@ var appRemoveCommand = &cli.Command{
|
||||
logrus.Info("no volumes were removed")
|
||||
}
|
||||
} else {
|
||||
if Volumes {
|
||||
logrus.Info("no volumes to remove")
|
||||
}
|
||||
}
|
||||
|
||||
err = os.Remove(app.Path)
|
||||
if err != nil {
|
||||
@ -153,16 +170,5 @@ var appRemoveCommand = &cli.Command{
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
70
cli/app/restart.go
Normal file
70
cli/app/restart.go
Normal file
@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
upstream "coopcloud.tech/abra/pkg/upstream/service"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appRestartCommand = cli.Command{
|
||||
Name: "restart",
|
||||
Aliases: []string{"re"},
|
||||
Usage: "Restart an app",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `This command restarts a service within a deployed app.`,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
serviceNameShort := c.Args().Get(1)
|
||||
if serviceNameShort == "" {
|
||||
err := errors.New("missing service?")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
|
||||
|
||||
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName)
|
||||
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 0); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName)
|
||||
|
||||
logrus.Debugf("attempting to scale %s to 1 (restart logic)", serviceName)
|
||||
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 1); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("%s has been scaled to 1 (restart logic)", serviceName)
|
||||
|
||||
logrus.Infof("%s service successfully restarted", serviceNameShort)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var restoreAllServices bool
|
||||
var restoreAllServicesFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Value: false,
|
||||
Destination: &restoreAllServices,
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Restore all services",
|
||||
}
|
||||
|
||||
var appRestoreCommand = &cli.Command{
|
||||
Name: "restore",
|
||||
Usage: "Restore an app from a backup",
|
||||
Aliases: []string{"r"},
|
||||
Flags: []cli.Flag{restoreAllServicesFlag},
|
||||
ArgsUsage: "<service> [<backup file>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() > 1 && restoreAllServices {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> 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_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 {
|
||||
logrus.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)
|
||||
if backupFile != "" {
|
||||
execCmd = fmt.Sprintf("%s %s", execCmd, backupFile)
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
@ -1,30 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
stack "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/tagcmp"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appRollbackCommand = &cli.Command{
|
||||
var appRollbackCommand = cli.Command{
|
||||
Name: "rollback",
|
||||
Aliases: []string{"rl"},
|
||||
Usage: "Roll an app back to a previous version",
|
||||
Aliases: []string{"r", "downgrade"},
|
||||
ArgsUsage: "<app>",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.NoDomainChecksFlag,
|
||||
internal.DontWaitConvergeFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
This command rolls an app back to a previous version if one exists.
|
||||
|
||||
@ -32,54 +39,66 @@ You may pass "--force/-f" to downgrade to the same version again. This can be
|
||||
useful if the container runtime has gotten into a weird state.
|
||||
|
||||
This action could be destructive, please ensure you have a copy of your app
|
||||
data beforehand - see "abra app backup <app>" for more.
|
||||
data beforehand.
|
||||
|
||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
recipes.
|
||||
`,
|
||||
BashComplete: 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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
r, err := recipe.Get(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 && !internal.Chaos {
|
||||
logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Recipe)
|
||||
}
|
||||
|
||||
var availableDowngrades []string
|
||||
if deployedVersion == "" {
|
||||
deployedVersion = "unknown"
|
||||
if deployedVersion == "unknown" {
|
||||
availableDowngrades = versions
|
||||
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
|
||||
logrus.Warnf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if deployedVersion != "unknown" && !internal.Chaos {
|
||||
@ -98,23 +117,21 @@ recipes.
|
||||
}
|
||||
|
||||
if len(availableDowngrades) == 0 {
|
||||
logrus.Fatal("no available downgrades, you're on latest")
|
||||
logrus.Info("no available downgrades, you're on oldest ✌️")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: jeezus golang why do you not have a list reverse function
|
||||
for i, j := 0, len(availableDowngrades)-1; i < j; i, j = i+1, j-1 {
|
||||
availableDowngrades[i], availableDowngrades[j] = availableDowngrades[j], availableDowngrades[i]
|
||||
}
|
||||
availableDowngrades = internal.ReverseStringList(availableDowngrades)
|
||||
|
||||
var chosenDowngrade string
|
||||
if !internal.Chaos {
|
||||
if internal.Force {
|
||||
if internal.Force || internal.NoInput {
|
||||
chosenDowngrade = availableDowngrades[0]
|
||||
logrus.Debugf("choosing '%s' as version to downgrade to (--force)", chosenDowngrade)
|
||||
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),
|
||||
Message: fmt.Sprintf("Please select a downgrade (current version: %s):", deployedVersion),
|
||||
Options: availableDowngrades,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
|
||||
@ -124,7 +141,7 @@ recipes.
|
||||
}
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureVersion(app.Type, chosenDowngrade); err != nil {
|
||||
if err := recipe.EnsureVersion(app.Recipe, chosenDowngrade); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -132,13 +149,13 @@ recipes.
|
||||
if internal.Chaos {
|
||||
logrus.Warn("chaos mode engaged")
|
||||
var err error
|
||||
chosenDowngrade, err = recipe.ChaosVersion(app.Type)
|
||||
chosenDowngrade, err = recipe.ChaosVersion(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
|
||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
@ -147,7 +164,7 @@ recipes.
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Recipe, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -163,12 +180,12 @@ recipes.
|
||||
}
|
||||
|
||||
if !internal.Force {
|
||||
if err := NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
|
||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
|
@ -1,51 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/client/container"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
containerPkg "coopcloud.tech/abra/pkg/container"
|
||||
"coopcloud.tech/abra/pkg/upstream/container"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var user string
|
||||
var userFlag = &cli.StringFlag{
|
||||
Name: "user",
|
||||
Name: "user, u",
|
||||
Value: "",
|
||||
Destination: &user,
|
||||
}
|
||||
|
||||
var noTTY bool
|
||||
var noTTYFlag = &cli.BoolFlag{
|
||||
Name: "no-tty",
|
||||
Value: false,
|
||||
Name: "no-tty, t",
|
||||
Destination: &noTTY,
|
||||
}
|
||||
|
||||
var appRunCommand = &cli.Command{
|
||||
var appRunCommand = cli.Command{
|
||||
Name: "run",
|
||||
Aliases: []string{"r"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
noTTYFlag,
|
||||
userFlag,
|
||||
},
|
||||
Aliases: []string{"r"},
|
||||
ArgsUsage: "<service> <args>...",
|
||||
Before: internal.SubCommandBefore,
|
||||
ArgsUsage: "<domain> <service> <args>...",
|
||||
Usage: "Run a command in a service container",
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() < 2 {
|
||||
if len(c.Args()) < 2 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no <service> provided?"))
|
||||
}
|
||||
|
||||
if c.Args().Len() < 3 {
|
||||
if len(c.Args()) < 3 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no <args> provided?"))
|
||||
}
|
||||
|
||||
@ -55,23 +59,16 @@ var appRunCommand = &cli.Command{
|
||||
}
|
||||
|
||||
serviceName := c.Args().Get(1)
|
||||
stackAndServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
|
||||
stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), serviceName)
|
||||
filters := filters.NewArgs()
|
||||
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 {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
switch len(containers) {
|
||||
case 0:
|
||||
logrus.Fatalf("no containers matching '%s' found?", stackAndServiceName)
|
||||
case 1:
|
||||
logrus.Fatalf("expected 1 container matching '%s' but got %d", stackAndServiceName, len(containers))
|
||||
}
|
||||
|
||||
cmd := c.Args().Slice()[2:]
|
||||
cmd := c.Args()[2:]
|
||||
execCreateOpts := types.ExecConfig{
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
@ -88,41 +85,16 @@ var appRunCommand = &cli.Command{
|
||||
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.
|
||||
// FIXME: avoid instantiating a new CLI
|
||||
dcli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
logrus.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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -1,41 +1,54 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var allSecrets bool
|
||||
var allSecretsFlag = &cli.BoolFlag{
|
||||
Name: "all",
|
||||
Aliases: []string{"A"},
|
||||
Value: false,
|
||||
Name: "all, a",
|
||||
Destination: &allSecrets,
|
||||
Usage: "Generate all secrets",
|
||||
}
|
||||
|
||||
var appSecretGenerateCommand = &cli.Command{
|
||||
var rmAllSecrets bool
|
||||
var rmAllSecretsFlag = &cli.BoolFlag{
|
||||
Name: "all, a",
|
||||
Destination: &rmAllSecrets,
|
||||
Usage: "Remove all secrets",
|
||||
}
|
||||
|
||||
var appSecretGenerateCommand = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate secrets",
|
||||
ArgsUsage: "<secret> <version>",
|
||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
||||
ArgsUsage: "<domain> <secret> <version>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
allSecretsFlag,
|
||||
internal.PassFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() == 1 && !allSecrets {
|
||||
if len(c.Args()) == 1 && !allSecrets {
|
||||
err := errors.New("missing arguments <secret>/<version> or '--all'")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
@ -57,10 +70,12 @@ var appSecretGenerateCommand = &cli.Command{
|
||||
parsed := secret.ParseSecretEnvVarName(sec)
|
||||
if secretName == parsed {
|
||||
secretsToCreate[sec] = secretVersion
|
||||
matches = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matches {
|
||||
logrus.Fatalf("'%s' doesn't exist in the env config?", secretName)
|
||||
logrus.Fatalf("%s doesn't exist in the env config?", secretName)
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +86,7 @@ var appSecretGenerateCommand = &cli.Command{
|
||||
|
||||
if internal.Pass {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -83,7 +98,7 @@ var appSecretGenerateCommand = &cli.Command{
|
||||
}
|
||||
|
||||
tableCol := []string{"name", "value"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
table := formatter.CreateTable(tableCol)
|
||||
for name, val := range secretVals {
|
||||
table.Append([]string{name, val})
|
||||
}
|
||||
@ -94,16 +109,33 @@ var appSecretGenerateCommand = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretInsertCommand = &cli.Command{
|
||||
var appSecretInsertCommand = cli.Command{
|
||||
Name: "insert",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Insert secret",
|
||||
Flags: []cli.Flag{internal.PassFlag},
|
||||
ArgsUsage: "<secret> <version> <data>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.PassFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
ArgsUsage: "<domain> <secret-name> <version> <data>",
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Description: `
|
||||
This command inserts a secret into an app environment.
|
||||
|
||||
This can be useful when you want to manually generate secrets for an app
|
||||
environment. Typically, you can let Abra generate them for you on app creation
|
||||
(see "abra app new --secrets" for more).
|
||||
|
||||
Example:
|
||||
|
||||
abra app secret insert myapp db_pass v1 mySecretPassword
|
||||
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Len() != 4 {
|
||||
if len(c.Args()) != 4 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
|
||||
}
|
||||
|
||||
@ -116,8 +148,10 @@ var appSecretInsertCommand = &cli.Command{
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("%s successfully stored on server", secretName)
|
||||
|
||||
if internal.Pass {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -126,20 +160,54 @@ var appSecretInsertCommand = &cli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretRmCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove a secret",
|
||||
Aliases: []string{"rm"},
|
||||
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
|
||||
ArgsUsage: "<secret>",
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
if c.Args().Get(1) != "" && allSecrets {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret>' and '--all' together"))
|
||||
// secretRm removes a secret.
|
||||
func secretRm(cl *dockerClient.Client, app config.App, secretName, parsed string) error {
|
||||
if err := cl.SecretRemove(context.Background(), secretName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Args().Get(1) == "" && !allSecrets {
|
||||
logrus.Infof("deleted %s successfully from server", secretName)
|
||||
|
||||
if internal.PassRemove {
|
||||
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Infof("deleted %s successfully from local pass store", secretName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var appSecretRmCommand = cli.Command{
|
||||
Name: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Usage: "Remove a secret",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
rmAllSecretsFlag,
|
||||
internal.PassRemoveFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
ArgsUsage: "<domain> [<secret-name>]",
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Description: `
|
||||
This command removes app secrets.
|
||||
|
||||
Example:
|
||||
|
||||
abra app secret remove myapp db_pass
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
secrets := secret.ReadSecretEnvVars(app.Env)
|
||||
|
||||
if c.Args().Get(1) != "" && rmAllSecrets {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
|
||||
}
|
||||
|
||||
if c.Args().Get(1) == "" && !rmAllSecrets {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
|
||||
}
|
||||
|
||||
@ -148,63 +216,89 @@ var appSecretRmCommand = &cli.Command{
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
|
||||
filters, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secretToRm := c.Args().Get(1)
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
remoteSecretNames := make(map[string]bool)
|
||||
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 {
|
||||
remoteSecretNames[cont.Spec.Annotations.Name] = true
|
||||
}
|
||||
|
||||
match := false
|
||||
secretToRm := c.Args().Get(1)
|
||||
for sec := range secrets {
|
||||
secretName := secret.ParseSecretEnvVarName(sec)
|
||||
|
||||
secVal, err := secret.ParseSecretEnvVarValue(secrets[sec])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if internal.Pass {
|
||||
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
|
||||
|
||||
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, secVal.Version)
|
||||
if _, ok := remoteSecretNames[secretRemoteName]; ok {
|
||||
if secretToRm != "" {
|
||||
if secretName == secretToRm {
|
||||
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
} 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 {
|
||||
match = true
|
||||
|
||||
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !match && secretToRm != "" {
|
||||
logrus.Fatalf("%s doesn't exist on server?", secretToRm)
|
||||
}
|
||||
|
||||
if !match {
|
||||
logrus.Fatal("no secrets to remove?")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var appSecretLsCommand = &cli.Command{
|
||||
var appSecretLsCommand = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all secrets",
|
||||
Aliases: []string{"ls"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "List all secrets",
|
||||
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)
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
filters := filters.NewArgs()
|
||||
filters.Add("name", app.StackName())
|
||||
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
|
||||
filters, err := app.Filters(false, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -229,29 +323,23 @@ var appSecretLsCommand = &cli.Command{
|
||||
table.Append(tableRow)
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
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)
|
||||
}
|
||||
},
|
||||
} else {
|
||||
logrus.Warnf("no secrets stored for %s", app.Name)
|
||||
}
|
||||
|
||||
var appSecretCommand = &cli.Command{
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
var appSecretCommand = cli.Command{
|
||||
Name: "secret",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Manage app secrets",
|
||||
ArgsUsage: "<command>",
|
||||
Subcommands: []*cli.Command{
|
||||
ArgsUsage: "<domain>",
|
||||
Subcommands: []cli.Command{
|
||||
appSecretGenerateCommand,
|
||||
appSecretInsertCommand,
|
||||
appSecretRmCommand,
|
||||
|
@ -1,19 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
stack "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appUndeployCommand = &cli.Command{
|
||||
var appUndeployCommand = cli.Command{
|
||||
Name: "undeploy",
|
||||
Aliases: []string{"u"},
|
||||
Aliases: []string{"un"},
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "Undeploy an app",
|
||||
Description: `
|
||||
This does not destroy any of the application data. However, you should remain
|
||||
@ -29,38 +35,27 @@ volumes as eligiblef or pruning once undeployed.
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("'%s' is not deployed?", stackName)
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
if err := DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
|
||||
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
|
||||
if err := stack.RunRemove(c.Context, cl, rmOpts); err != nil {
|
||||
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
|
||||
logrus.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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
@ -1,42 +1,49 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
stack "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appUpgradeCommand = &cli.Command{
|
||||
var appUpgradeCommand = cli.Command{
|
||||
Name: "upgrade",
|
||||
Aliases: []string{"u"},
|
||||
Aliases: []string{"up"},
|
||||
Usage: "Upgrade an app",
|
||||
ArgsUsage: "<app>",
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.ForceFlag,
|
||||
internal.ChaosFlag,
|
||||
internal.NoDomainChecksFlag,
|
||||
internal.DontWaitConvergeFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
This command supports upgrading an app. You can use it to choose and roll out a
|
||||
new upgrade to an existing app.
|
||||
|
||||
This command specifically supports changing the version of running apps, as
|
||||
opposed to "abra app deploy <app>" which will not change the version of a
|
||||
This command specifically supports incrementing the version of running apps, as
|
||||
opposed to "abra app deploy <domain>" 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
|
||||
useful if the container runtime has gotten into a weird state.
|
||||
|
||||
This action could be destructive, please ensure you have a copy of your app
|
||||
data beforehand - see "abra app backup <app>" for more.
|
||||
data beforehand.
|
||||
|
||||
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
|
||||
including unstaged changes and can be useful for live hacking and testing new
|
||||
@ -46,36 +53,55 @@ recipes.
|
||||
app := internal.ValidateApp(c)
|
||||
stackName := app.StackName()
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
r, err := recipe.Get(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 && !internal.Chaos {
|
||||
logrus.Fatalf("no versions available '%s' in recipe catalogue?", app.Type)
|
||||
logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Recipe)
|
||||
}
|
||||
|
||||
var availableUpgrades []string
|
||||
if deployedVersion == "" {
|
||||
deployedVersion = "unknown"
|
||||
if deployedVersion == "unknown" {
|
||||
availableUpgrades = versions
|
||||
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
|
||||
logrus.Warnf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if deployedVersion != "unknown" && !internal.Chaos {
|
||||
@ -94,19 +120,21 @@ recipes.
|
||||
}
|
||||
|
||||
if len(availableUpgrades) == 0 && !internal.Force {
|
||||
logrus.Fatal("no available upgrades, you're on latest")
|
||||
availableUpgrades = versions
|
||||
logrus.Infof("no available upgrades, you're on latest (%s) ✌️", deployedVersion)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
availableUpgrades = internal.ReverseStringList(availableUpgrades)
|
||||
|
||||
var chosenUpgrade string
|
||||
if len(availableUpgrades) > 0 && !internal.Chaos {
|
||||
if internal.Force {
|
||||
if internal.Force || internal.NoInput {
|
||||
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
|
||||
logrus.Debugf("choosing '%s' as version to upgrade to", chosenUpgrade)
|
||||
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),
|
||||
Message: fmt.Sprintf("Please select an upgrade (current version: %s):", deployedVersion),
|
||||
Options: availableUpgrades,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
|
||||
@ -115,8 +143,16 @@ recipes.
|
||||
}
|
||||
}
|
||||
|
||||
// if release notes written after git tag published, read them before we
|
||||
// check out the tag and then they'll appear to be missing. this covers
|
||||
// when we obviously will forget to write release notes before publishing
|
||||
releaseNotes, err := internal.GetReleaseNotes(app.Recipe, chosenUpgrade)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !internal.Chaos {
|
||||
if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
|
||||
if err := recipe.EnsureVersion(app.Recipe, chosenUpgrade); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -124,13 +160,13 @@ recipes.
|
||||
if internal.Chaos {
|
||||
logrus.Warn("chaos mode engaged")
|
||||
var err error
|
||||
chosenUpgrade, err = recipe.ChaosVersion(app.Type)
|
||||
chosenUpgrade, err = recipe.ChaosVersion(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
|
||||
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
@ -139,7 +175,7 @@ recipes.
|
||||
app.Env[k] = v
|
||||
}
|
||||
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
|
||||
composeFiles, err := config.GetAppComposeFiles(app.Recipe, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -154,26 +190,15 @@ recipes.
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := NewVersionOverview(app, deployedVersion, chosenUpgrade); err != nil {
|
||||
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil {
|
||||
logrus.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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"context"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"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/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// getImagePath returns the image name
|
||||
@ -21,17 +20,25 @@ func getImagePath(image string) (string, error) {
|
||||
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)
|
||||
|
||||
path = formatter.StripTagMeta(path)
|
||||
|
||||
logrus.Debugf("parsed %s from %s", path, image)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
var appVersionCommand = &cli.Command{
|
||||
var appVersionCommand = cli.Command{
|
||||
Name: "version",
|
||||
Aliases: []string{"v"},
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "Show app versions",
|
||||
Description: `
|
||||
This command shows all information about versioning related to a deployed app.
|
||||
@ -47,27 +54,27 @@ Cloud recipe version.
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether '%s' is already deployed", stackName)
|
||||
logrus.Debugf("checking whether %s is already deployed", stackName)
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if deployedVersion == "" {
|
||||
logrus.Fatalf("failed to determine version of deployed '%s'", app.Name)
|
||||
if deployedVersion == "unknown" {
|
||||
logrus.Fatalf("failed to determine version of deployed %s", app.Name)
|
||||
}
|
||||
|
||||
if !isDeployed {
|
||||
logrus.Fatalf("'%s' is not deployed?", app.Name)
|
||||
logrus.Fatalf("%s is not deployed?", app.Name)
|
||||
}
|
||||
|
||||
recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
|
||||
recipeMeta, err := recipe.GetRecipeMeta(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
versionsMeta := make(map[string]catalogue.ServiceMeta)
|
||||
versionsMeta := make(map[string]recipe.ServiceMeta)
|
||||
for _, recipeVersion := range recipeMeta.Versions {
|
||||
if currentVersion, exists := recipeVersion[deployedVersion]; exists {
|
||||
versionsMeta = currentVersion
|
||||
@ -75,30 +82,20 @@ Cloud recipe version.
|
||||
}
|
||||
|
||||
if len(versionsMeta) == 0 {
|
||||
logrus.Fatalf("PANIC: could not retrieve deployed version ('%s') from recipe catalogue?", deployedVersion)
|
||||
logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion)
|
||||
}
|
||||
|
||||
tableCol := []string{"name", "image", "version", "tag", "digest"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
tableCol := []string{"version", "service", "image", "digest"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table.SetAutoMergeCellsByColumnIndex([]int{0})
|
||||
|
||||
for serviceName, versionMeta := range versionsMeta {
|
||||
table.Append([]string{serviceName, versionMeta.Image, deployedVersion, versionMeta.Tag, versionMeta.Digest})
|
||||
table.Append([]string{deployedVersion, serviceName, versionMeta.Image, 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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
@ -1,77 +1,115 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var appVolumeListCommand = &cli.Command{
|
||||
var appVolumeListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List volumes associated with an app",
|
||||
Aliases: []string{"ls"},
|
||||
ArgsUsage: "<domain>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "List volumes associated with an app",
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
|
||||
filters, err := app.Filters(false, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
table := abraFormatter.CreateTable([]string{"driver", "volume name"})
|
||||
volumeList, err := client.GetVolumes(context.Background(), app.Server, filters)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
table := formatter.CreateTable([]string{"name", "created", "mounted"})
|
||||
var volTable [][]string
|
||||
for _, volume := range volumeList {
|
||||
volRow := []string{
|
||||
volume.Driver,
|
||||
volume.Name,
|
||||
}
|
||||
volRow := []string{volume.Name, volume.CreatedAt, volume.Mountpoint}
|
||||
volTable = append(volTable, volRow)
|
||||
}
|
||||
|
||||
table.AppendBulk(volTable)
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
} else {
|
||||
logrus.Warnf("no volumes created for %s", app.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var appVolumeRemoveCommand = &cli.Command{
|
||||
var appVolumeRemoveCommand = cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove volume(s) associated with an app",
|
||||
Description: `
|
||||
This command supports removing volumes associated with an app. The app in
|
||||
question must be undeployed before you try to remove volumes. See "abra app
|
||||
undeploy <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.
|
||||
`,
|
||||
ArgsUsage: "<domain>",
|
||||
Aliases: []string{"rm"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.ForceFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
app := internal.ValidateApp(c)
|
||||
|
||||
volumeList, err := client.GetVolumes(c.Context, app.Server, app.Name)
|
||||
filters, err := app.Filters(false, true)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
volumeList, err := client.GetVolumes(context.Background(), app.Server, filters)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
volumeNames := client.GetVolumeNames(volumeList)
|
||||
|
||||
var volumesToRemove []string
|
||||
if !internal.Force {
|
||||
if !internal.Force && !internal.NoInput {
|
||||
volumesPrompt := &survey.MultiSelect{
|
||||
Message: "which volumes do you want to remove?",
|
||||
Help: "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: volumeNames,
|
||||
Default: volumeNames,
|
||||
}
|
||||
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if internal.Force || internal.NoInput {
|
||||
volumesToRemove = volumeNames
|
||||
}
|
||||
|
||||
err = client.RemoveVolumes(c.Context, app.Server, volumesToRemove, internal.Force)
|
||||
err = client.RemoveVolumes(context.Background(), app.Server, volumesToRemove, internal.Force)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
@ -80,26 +118,15 @@ var appVolumeRemoveCommand = &cli.Command{
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
BashComplete: autocomplete.AppNameComplete,
|
||||
}
|
||||
|
||||
var appVolumeCommand = &cli.Command{
|
||||
var appVolumeCommand = cli.Command{
|
||||
Name: "volume",
|
||||
Aliases: []string{"vl"},
|
||||
Usage: "Manage app volumes",
|
||||
ArgsUsage: "<command>",
|
||||
Subcommands: []*cli.Command{
|
||||
ArgsUsage: "<domain>",
|
||||
Subcommands: []cli.Command{
|
||||
appVolumeListCommand,
|
||||
appVolumeRemoveCommand,
|
||||
},
|
||||
|
@ -1,17 +1,317 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/limit"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// CatalogueSkipList is all the repos that are not recipes.
|
||||
var CatalogueSkipList = map[string]bool{
|
||||
"abra": true,
|
||||
"abra-apps": true,
|
||||
"abra-aur": true,
|
||||
"abra-bash": true,
|
||||
"abra-capsul": true,
|
||||
"abra-gandi": true,
|
||||
"abra-hetzner": true,
|
||||
"apps": true,
|
||||
"aur-abra-git": true,
|
||||
"auto-apps-json": true,
|
||||
"auto-mirror": true,
|
||||
"backup-bot": true,
|
||||
"backup-bot-two": true,
|
||||
"beta.coopcloud.tech": true,
|
||||
"comrade-renovate-bot": true,
|
||||
"coopcloud.tech": true,
|
||||
"coturn": true,
|
||||
"docker-cp-deploy": true,
|
||||
"docker-dind-bats-kcov": true,
|
||||
"docs.coopcloud.tech": true,
|
||||
"drone-abra": true,
|
||||
"example": true,
|
||||
"gardening": true,
|
||||
"go-abra": true,
|
||||
"organising": true,
|
||||
"outline-with-patch": true,
|
||||
"pyabra": true,
|
||||
"radicle-seed-node": true,
|
||||
"recipes-catalogue-json": true,
|
||||
"recipes-wishlist": true,
|
||||
"recipes.coopcloud.tech": true,
|
||||
"stack-ssh-deploy": true,
|
||||
"swarm-cronjob": true,
|
||||
"tagcmp": true,
|
||||
"traefik-cert-dumper": true,
|
||||
"tyop": true,
|
||||
}
|
||||
|
||||
var catalogueGenerateCommand = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate the recipe catalogue",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.PublishFlag,
|
||||
internal.DryFlag,
|
||||
internal.SkipUpdatesFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
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.md and git tags of those repositories to produce recipe
|
||||
metadata and produces a recipes JSON file.
|
||||
|
||||
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 have Abra log you in to avoid this. Pass
|
||||
"--user" and "--pass".
|
||||
|
||||
Push your new release git.coopcloud.tech with "-p/--publish". This requires
|
||||
that you have permission to git push to these repositories and have your SSH
|
||||
keys configured on your account.
|
||||
`,
|
||||
ArgsUsage: "[<recipe>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
if recipeName != "" {
|
||||
internal.ValidateRecipe(c, true)
|
||||
}
|
||||
|
||||
repos, err := recipe.ReadReposMetadata()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var barLength int
|
||||
var logMsg string
|
||||
if recipeName != "" {
|
||||
barLength = 1
|
||||
logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
|
||||
} else {
|
||||
barLength = len(repos)
|
||||
logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
|
||||
}
|
||||
|
||||
if !internal.SkipUpdates {
|
||||
logrus.Warn(logMsg)
|
||||
if err := updateRepositories(repos, recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
catl := make(recipe.RecipeCatalogue)
|
||||
catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...")
|
||||
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 := recipe.GetRecipeVersions(recipeMeta.Name)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
}
|
||||
|
||||
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
catlBar.Add(1)
|
||||
}
|
||||
|
||||
recipesJSON, err := json.MarshalIndent(catl, "", " ")
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if recipeName == "" {
|
||||
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
catlFS, err := recipe.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.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
|
||||
|
||||
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
|
||||
if internal.Publish {
|
||||
|
||||
isClean, err := gitPkg.IsClean(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if isClean {
|
||||
if !internal.Dry {
|
||||
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
|
||||
}
|
||||
}
|
||||
|
||||
msg := "chore: publish new catalogue release changes"
|
||||
if err := gitPkg.Commit(cataloguePath, "**.json", msg, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
|
||||
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(cataloguePath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !internal.Dry && internal.Publish {
|
||||
url := fmt.Sprintf("%s/%s/commit/%s", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME, head.Hash())
|
||||
logrus.Infof("new changes published: %s", url)
|
||||
}
|
||||
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changes published")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
||||
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
|
||||
var CatalogueCommand = &cli.Command{
|
||||
var CatalogueCommand = cli.Command{
|
||||
Name: "catalogue",
|
||||
Usage: "Manage the recipe catalogue (for maintainers)",
|
||||
Usage: "Manage the recipe catalogue",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: "This command helps recipe packagers interact with the recipe catalogue",
|
||||
Subcommands: []*cli.Command{
|
||||
Subcommands: []cli.Command{
|
||||
catalogueGenerateCommand,
|
||||
},
|
||||
}
|
||||
|
||||
func updateRepositories(repos recipe.RepoCatalogue, recipeName string) error {
|
||||
var barLength int
|
||||
if recipeName != "" {
|
||||
barLength = 1
|
||||
} else {
|
||||
barLength = len(repos)
|
||||
}
|
||||
|
||||
cloneLimiter := limit.New(10)
|
||||
|
||||
retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
|
||||
ch := make(chan string, barLength)
|
||||
for _, repoMeta := range repos {
|
||||
go func(rm recipe.RepoMeta) {
|
||||
cloneLimiter.Begin()
|
||||
defer cloneLimiter.End()
|
||||
|
||||
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.RECIPES_DIR, rm.Name)
|
||||
|
||||
if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
isClean, err := gitPkg.IsClean(recipeDir)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !isClean {
|
||||
logrus.Fatalf("%s has locally unstaged changes", rm.Name)
|
||||
}
|
||||
|
||||
if err := recipe.EnsureUpToDate(rm.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
ch <- rm.Name
|
||||
retrieveBar.Add(1)
|
||||
}(repoMeta)
|
||||
}
|
||||
|
||||
for range repos {
|
||||
<-ch // wait for everything
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,173 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"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,
|
||||
"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 catalogueGenerateCommand = &cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "Generate a new copy of the catalogue",
|
||||
ArgsUsage: "[<recipe>]",
|
||||
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)
|
||||
}
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
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))
|
||||
|
||||
retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes...")
|
||||
ch := make(chan string, len(repos))
|
||||
for _, repoMeta := range repos {
|
||||
go func(rm catalogue.RepoMeta) {
|
||||
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 := git.Clone(recipeDir, rm.SSHURL); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := git.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: once we sort out the machine-readable catalogue interface
|
||||
// Features: ..., // FIXME: once we figure out the machine-readable catalogue interface
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof("generated new recipe catalogue in '%s'", config.APPS_JSON)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
177
cli/cli.go
177
cli/cli.go
@ -2,50 +2,148 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/cli/app"
|
||||
"coopcloud.tech/abra/cli/catalogue"
|
||||
"coopcloud.tech/abra/cli/domain"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/cli/recipe"
|
||||
"coopcloud.tech/abra/cli/record"
|
||||
"coopcloud.tech/abra/cli/server"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
logrusStack "github.com/Gurpartap/logrus-stack"
|
||||
"coopcloud.tech/abra/pkg/web"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// Verbose stores the variable from VerboseFlag.
|
||||
var Verbose bool
|
||||
// AutoCompleteCommand helps people set up auto-complete in their shells
|
||||
var AutoCompleteCommand = cli.Command{
|
||||
Name: "autocomplete",
|
||||
Aliases: []string{"ac"},
|
||||
Usage: "Configure shell autocompletion (recommended)",
|
||||
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.
|
||||
|
||||
// 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",
|
||||
Example:
|
||||
|
||||
abra autocomplete bash
|
||||
|
||||
Supported shells are as follows:
|
||||
|
||||
fizsh
|
||||
zsh
|
||||
bash
|
||||
`,
|
||||
ArgsUsage: "<shell>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
shellType := c.Args().First()
|
||||
|
||||
if shellType == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
|
||||
}
|
||||
|
||||
// Debug stores the variable from DebugFlag.
|
||||
var Debug bool
|
||||
supportedShells := map[string]bool{
|
||||
"bash": true,
|
||||
"zsh": true,
|
||||
"fizsh": true,
|
||||
}
|
||||
|
||||
// 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",
|
||||
if _, ok := supportedShells[shellType]; !ok {
|
||||
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
|
||||
}
|
||||
|
||||
if shellType == "fizsh" {
|
||||
shellType = "zsh" // handled the same on the autocompletion side
|
||||
}
|
||||
|
||||
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
|
||||
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
|
||||
if !os.IsExist(err) {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("%s already created", autocompletionDir)
|
||||
}
|
||||
|
||||
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
|
||||
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
|
||||
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
|
||||
logrus.Infof("fetching %s", url)
|
||||
if err := web.GetFile(autocompletionFile, url); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
switch shellType {
|
||||
case "bash":
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
# Run the following commands to install autocompletion
|
||||
sudo mkdir /etc/bash_completion.d/
|
||||
sudo cp %s /etc/bash_completion.d/abra
|
||||
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
|
||||
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app domains listed!
|
||||
`, autocompletionFile))
|
||||
case "zsh":
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
# Run the following commands to install autocompletion
|
||||
sudo mkdir /etc/zsh/completion.d/
|
||||
sudo cp %s /etc/zsh/completion.d/abra
|
||||
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
|
||||
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app domains listed!
|
||||
`, autocompletionFile))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// UpgradeCommand upgrades abra in-place.
|
||||
var UpgradeCommand = cli.Command{
|
||||
Name: "upgrade",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Upgrade Abra itself",
|
||||
Description: `
|
||||
This command allows you to upgrade Abra in-place with the latest stable or
|
||||
release candidate.
|
||||
|
||||
If you would like to install the latest release candidate, please pass the
|
||||
"-r/--rc" option. Please bear in mind that the latest release candidate may
|
||||
have some catastrophic bugs contained in it. In any case, thank you very much
|
||||
for the testing efforts!
|
||||
`,
|
||||
Flags: []cli.Flag{internal.RCFlag},
|
||||
Action: func(c *cli.Context) error {
|
||||
mainURL := "https://install.abra.coopcloud.tech"
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
|
||||
|
||||
if internal.RC {
|
||||
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
|
||||
cmd = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
|
||||
}
|
||||
|
||||
logrus.Debugf("attempting to run %s", cmd)
|
||||
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func newAbraApp(version, commit string) *cli.App {
|
||||
app := &cli.App{
|
||||
Name: "abra",
|
||||
Usage: `The Co-op Cloud command-line utility belt 🎩🐇
|
||||
|
||||
____ ____ _ _
|
||||
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
|
||||
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
|
||||
@ -54,55 +152,38 @@ func newAbraApp(version, commit string) *cli.App {
|
||||
|_|
|
||||
`,
|
||||
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
|
||||
Commands: []*cli.Command{
|
||||
Commands: []cli.Command{
|
||||
app.AppCommand,
|
||||
server.ServerCommand,
|
||||
recipe.RecipeCommand,
|
||||
catalogue.CatalogueCommand,
|
||||
domain.DomainCommand,
|
||||
record.RecordCommand,
|
||||
UpgradeCommand,
|
||||
AutoCompleteCommand,
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
VerboseFlag,
|
||||
DebugFlag,
|
||||
},
|
||||
Authors: []*cli.Author{
|
||||
{
|
||||
Name: "Autonomic Co-op",
|
||||
Email: "helo@autonomic.zone",
|
||||
},
|
||||
},
|
||||
BashComplete: autocomplete.SubcommandComplete,
|
||||
}
|
||||
|
||||
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"),
|
||||
path.Join(config.SERVERS_DIR),
|
||||
path.Join(config.RECIPES_DIR),
|
||||
path.Join(config.VENDOR_DIR),
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if err := os.Mkdir(path, 0755); err != nil {
|
||||
if err := os.Mkdir(path, 0764); 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)
|
||||
logrus.Debugf("abra version %s, commit %s", version, commit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,150 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// DomainCreateCommand lists domains.
|
||||
var DomainCreateCommand = &cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Create a new domain record",
|
||||
Aliases: []string{"c"},
|
||||
ArgsUsage: "<zone> <type> <name> <value> [<ttl>] [<priority>]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DNSProviderFlag,
|
||||
},
|
||||
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 domain create -p gandi foo.com A myapp 192.168.178.44
|
||||
|
||||
Which means you can then deploy an app against "myapp.foo.com" successfully.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
zone := c.Args().First()
|
||||
if zone == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no zone provided"))
|
||||
}
|
||||
|
||||
recordType := c.Args().Get(1)
|
||||
recordName := c.Args().Get(2)
|
||||
recordValue := c.Args().Get(3)
|
||||
recordTTL := c.Args().Get(4)
|
||||
recordPriority := c.Args().Get(5)
|
||||
|
||||
if recordType == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no type provided"))
|
||||
}
|
||||
if recordName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
|
||||
}
|
||||
if recordValue == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no value provided"))
|
||||
}
|
||||
|
||||
if recordTTL == "" {
|
||||
recordTTL = "86400"
|
||||
}
|
||||
|
||||
if recordType == "MX" || recordType == "SRV" || recordType == "URI" {
|
||||
if recordPriority == "" {
|
||||
logrus.Fatal("record priority must be set when using MX/SRV/URI records")
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
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)
|
||||
}
|
||||
|
||||
ttl, err := strconv.Atoi(recordTTL)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
record := libdns.Record{
|
||||
Type: recordType,
|
||||
Name: recordName,
|
||||
Value: recordValue,
|
||||
TTL: time.Duration(ttl),
|
||||
}
|
||||
|
||||
if recordType == "MX" || recordType == "SRV" || recordType == "URI" {
|
||||
priority, err := strconv.Atoi(recordPriority)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
record.Priority = priority
|
||||
}
|
||||
|
||||
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 no record 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,127 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// DomainRemoveCommand lists domains.
|
||||
var DomainRemoveCommand = &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove domain name records for a zone",
|
||||
Aliases: []string{"rm"},
|
||||
ArgsUsage: "<zone> <type> <name>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DNSProviderFlag,
|
||||
},
|
||||
Description: `
|
||||
This command removes 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.
|
||||
|
||||
The "<id>" can be retrieved by running "abra domain list <zone>". This can be
|
||||
used then in this command for deletion of the record.
|
||||
|
||||
Example:
|
||||
|
||||
abra domain remove -p gandi foo.com A myapp
|
||||
|
||||
Which means that the domain name record of type A for myapp.foo.com will be deleted.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
zone := c.Args().First()
|
||||
if zone == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no zone provided"))
|
||||
}
|
||||
|
||||
recordType := c.Args().Get(1)
|
||||
recordName := c.Args().Get(2)
|
||||
|
||||
if recordType == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no type provided"))
|
||||
}
|
||||
if recordName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
|
||||
}
|
||||
|
||||
var err error
|
||||
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)
|
||||
}
|
||||
|
||||
var toDelete libdns.Record
|
||||
for _, record := range records {
|
||||
if record.Type == recordType && record.Name == recordName {
|
||||
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
|
||||
},
|
||||
}
|
433
cli/internal/cli.go
Normal file
433
cli/internal/cli.go
Normal file
@ -0,0 +1,433 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
logrusStack "github.com/Gurpartap/logrus-stack"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// Secrets stores the variable from SecretsFlag
|
||||
var Secrets bool
|
||||
|
||||
// SecretsFlag turns on/off automatically generating secrets
|
||||
var SecretsFlag = &cli.BoolFlag{
|
||||
Name: "secrets, S",
|
||||
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, p",
|
||||
Usage: "Store the generated secrets in a local pass store",
|
||||
Destination: &Pass,
|
||||
}
|
||||
|
||||
// PassRemove stores the variable for PassRemoveFlag
|
||||
var PassRemove bool
|
||||
|
||||
// PassRemoveFlag turns on/off removing generated secrets from pass
|
||||
var PassRemoveFlag = &cli.BoolFlag{
|
||||
Name: "pass, p",
|
||||
Usage: "Remove generated secrets from a local pass store",
|
||||
Destination: &PassRemove,
|
||||
}
|
||||
|
||||
// Force force functionality without asking.
|
||||
var Force bool
|
||||
|
||||
// ForceFlag turns on/off force functionality.
|
||||
var ForceFlag = &cli.BoolFlag{
|
||||
Name: "force, f",
|
||||
Usage: "Perform action without further prompt. Use with care!",
|
||||
Destination: &Force,
|
||||
}
|
||||
|
||||
// Chaos engages chaos mode.
|
||||
var Chaos bool
|
||||
|
||||
// ChaosFlag turns on/off chaos functionality.
|
||||
var ChaosFlag = &cli.BoolFlag{
|
||||
Name: "chaos, C",
|
||||
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, p",
|
||||
Value: "",
|
||||
Usage: "DNS provider",
|
||||
Destination: &DNSProvider,
|
||||
}
|
||||
|
||||
var NoInput bool
|
||||
var NoInputFlag = &cli.BoolFlag{
|
||||
Name: "no-input, n",
|
||||
Usage: "Toggle non-interactive mode",
|
||||
Destination: &NoInput,
|
||||
}
|
||||
|
||||
var DNSType string
|
||||
|
||||
var DNSTypeFlag = &cli.StringFlag{
|
||||
Name: "record-type, rt",
|
||||
Value: "",
|
||||
Usage: "Domain name record type (e.g. A)",
|
||||
Destination: &DNSType,
|
||||
}
|
||||
|
||||
var DNSName string
|
||||
|
||||
var DNSNameFlag = &cli.StringFlag{
|
||||
Name: "record-name, rn",
|
||||
Value: "",
|
||||
Usage: "Domain name record name (e.g. mysubdomain)",
|
||||
Destination: &DNSName,
|
||||
}
|
||||
|
||||
var DNSValue string
|
||||
|
||||
var DNSValueFlag = &cli.StringFlag{
|
||||
Name: "record-value, rv",
|
||||
Value: "",
|
||||
Usage: "Domain name record value (e.g. 192.168.1.1)",
|
||||
Destination: &DNSValue,
|
||||
}
|
||||
|
||||
var DNSTTL string
|
||||
var DNSTTLFlag = &cli.StringFlag{
|
||||
Name: "record-ttl, rl",
|
||||
Value: "600s",
|
||||
Usage: "Domain name TTL value (seconds)",
|
||||
Destination: &DNSTTL,
|
||||
}
|
||||
|
||||
var DNSPriority int
|
||||
|
||||
var DNSPriorityFlag = &cli.IntFlag{
|
||||
Name: "record-priority, rp",
|
||||
Value: 10,
|
||||
Usage: "Domain name priority value",
|
||||
Destination: &DNSPriority,
|
||||
}
|
||||
|
||||
var ServerProvider string
|
||||
|
||||
var ServerProviderFlag = &cli.StringFlag{
|
||||
Name: "provider, p",
|
||||
Usage: "3rd party server provider",
|
||||
Destination: &ServerProvider,
|
||||
}
|
||||
|
||||
var CapsulInstanceURL string
|
||||
|
||||
var CapsulInstanceURLFlag = &cli.StringFlag{
|
||||
Name: "capsul-url, cu",
|
||||
Value: "yolo.servers.coop",
|
||||
Usage: "capsul instance URL",
|
||||
Destination: &CapsulInstanceURL,
|
||||
}
|
||||
|
||||
var CapsulName string
|
||||
|
||||
var CapsulNameFlag = &cli.StringFlag{
|
||||
Name: "capsul-name, cn",
|
||||
Value: "",
|
||||
Usage: "capsul name",
|
||||
Destination: &CapsulName,
|
||||
}
|
||||
|
||||
var CapsulType string
|
||||
|
||||
var CapsulTypeFlag = &cli.StringFlag{
|
||||
Name: "capsul-type, ct",
|
||||
Value: "f1-xs",
|
||||
Usage: "capsul type",
|
||||
Destination: &CapsulType,
|
||||
}
|
||||
|
||||
var CapsulImage string
|
||||
|
||||
var CapsulImageFlag = &cli.StringFlag{
|
||||
Name: "capsul-image, ci",
|
||||
Value: "debian10",
|
||||
Usage: "capsul image",
|
||||
Destination: &CapsulImage,
|
||||
}
|
||||
|
||||
var CapsulSSHKeys cli.StringSlice
|
||||
var CapsulSSHKeysFlag = &cli.StringSliceFlag{
|
||||
Name: "capsul-ssh-keys, cs",
|
||||
Usage: "capsul SSH key",
|
||||
Value: &CapsulSSHKeys,
|
||||
}
|
||||
|
||||
var CapsulAPIToken string
|
||||
|
||||
var CapsulAPITokenFlag = &cli.StringFlag{
|
||||
Name: "capsul-token, ca",
|
||||
Usage: "capsul API token",
|
||||
EnvVar: "CAPSUL_TOKEN",
|
||||
Destination: &CapsulAPIToken,
|
||||
}
|
||||
|
||||
var HetznerCloudName string
|
||||
|
||||
var HetznerCloudNameFlag = &cli.StringFlag{
|
||||
Name: "hetzner-name, hn",
|
||||
Value: "",
|
||||
Usage: "hetzner cloud name",
|
||||
Destination: &HetznerCloudName,
|
||||
}
|
||||
|
||||
var HetznerCloudType string
|
||||
|
||||
var HetznerCloudTypeFlag = &cli.StringFlag{
|
||||
Name: "hetzner-type, ht",
|
||||
Usage: "hetzner cloud type",
|
||||
Destination: &HetznerCloudType,
|
||||
Value: "cx11",
|
||||
}
|
||||
|
||||
var HetznerCloudImage string
|
||||
|
||||
var HetznerCloudImageFlag = &cli.StringFlag{
|
||||
Name: "hetzner-image, hi",
|
||||
Usage: "hetzner cloud image",
|
||||
Value: "debian-10",
|
||||
Destination: &HetznerCloudImage,
|
||||
}
|
||||
|
||||
var HetznerCloudSSHKeys cli.StringSlice
|
||||
|
||||
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
|
||||
Name: "hetzner-ssh-keys, hs",
|
||||
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
|
||||
Value: &HetznerCloudSSHKeys,
|
||||
}
|
||||
|
||||
var HetznerCloudLocation string
|
||||
|
||||
var HetznerCloudLocationFlag = &cli.StringFlag{
|
||||
Name: "hetzner-location, hl",
|
||||
Usage: "hetzner cloud server location",
|
||||
Value: "hel1",
|
||||
Destination: &HetznerCloudLocation,
|
||||
}
|
||||
|
||||
var HetznerCloudAPIToken string
|
||||
|
||||
var HetznerCloudAPITokenFlag = &cli.StringFlag{
|
||||
Name: "hetzner-token, ha",
|
||||
Usage: "hetzner cloud API token",
|
||||
EnvVar: "HCLOUD_TOKEN",
|
||||
Destination: &HetznerCloudAPIToken,
|
||||
}
|
||||
|
||||
// Debug stores the variable from DebugFlag.
|
||||
var Debug bool
|
||||
|
||||
// DebugFlag turns on/off verbose logging down to the DEBUG level.
|
||||
var DebugFlag = &cli.BoolFlag{
|
||||
Name: "debug, d",
|
||||
Destination: &Debug,
|
||||
Usage: "Show DEBUG messages",
|
||||
}
|
||||
|
||||
// RC signifies the latest release candidate
|
||||
var RC bool
|
||||
|
||||
// RCFlag chooses the latest release candidate for install
|
||||
var RCFlag = &cli.BoolFlag{
|
||||
Name: "rc, r",
|
||||
Destination: &RC,
|
||||
Usage: "Insatll the latest release candidate",
|
||||
}
|
||||
|
||||
var Major bool
|
||||
var MajorFlag = &cli.BoolFlag{
|
||||
Name: "major, x",
|
||||
Usage: "Increase the major part of the version",
|
||||
Destination: &Major,
|
||||
}
|
||||
|
||||
var Minor bool
|
||||
var MinorFlag = &cli.BoolFlag{
|
||||
Name: "minor, y",
|
||||
Usage: "Increase the minor part of the version",
|
||||
Destination: &Minor,
|
||||
}
|
||||
|
||||
var Patch bool
|
||||
var PatchFlag = &cli.BoolFlag{
|
||||
Name: "patch, z",
|
||||
Usage: "Increase the patch part of the version",
|
||||
Destination: &Patch,
|
||||
}
|
||||
|
||||
var Dry bool
|
||||
var DryFlag = &cli.BoolFlag{
|
||||
Name: "dry-run, r",
|
||||
Usage: "Only reports changes that would be made",
|
||||
Destination: &Dry,
|
||||
}
|
||||
|
||||
var Publish bool
|
||||
var PublishFlag = &cli.BoolFlag{
|
||||
Name: "publish, p",
|
||||
Usage: "Publish changes to git.coopcloud.tech",
|
||||
Destination: &Publish,
|
||||
}
|
||||
|
||||
var Domain string
|
||||
var DomainFlag = &cli.StringFlag{
|
||||
Name: "domain, D",
|
||||
Value: "",
|
||||
Usage: "Choose a domain name",
|
||||
Destination: &Domain,
|
||||
}
|
||||
|
||||
var NewAppServer string
|
||||
var NewAppServerFlag = &cli.StringFlag{
|
||||
Name: "server, s",
|
||||
Value: "",
|
||||
Usage: "Show apps of a specific server",
|
||||
Destination: &NewAppServer,
|
||||
}
|
||||
|
||||
var NoDomainChecks bool
|
||||
var NoDomainChecksFlag = &cli.BoolFlag{
|
||||
Name: "no-domain-checks, D",
|
||||
Usage: "Disable app domain sanity checks",
|
||||
Destination: &NoDomainChecks,
|
||||
}
|
||||
|
||||
var StdErrOnly bool
|
||||
var StdErrOnlyFlag = &cli.BoolFlag{
|
||||
Name: "stderr, s",
|
||||
Usage: "Only tail stderr",
|
||||
Destination: &StdErrOnly,
|
||||
}
|
||||
|
||||
var DontWaitConverge bool
|
||||
var DontWaitConvergeFlag = &cli.BoolFlag{
|
||||
Name: "no-converge-checks, c",
|
||||
Usage: "Don't wait for converge logic checks",
|
||||
Destination: &DontWaitConverge,
|
||||
}
|
||||
|
||||
var Watch bool
|
||||
var WatchFlag = &cli.BoolFlag{
|
||||
Name: "watch, w",
|
||||
Usage: "Watch status by polling repeatedly",
|
||||
Destination: &Watch,
|
||||
}
|
||||
|
||||
var OnlyErrors bool
|
||||
var OnlyErrorFlag = &cli.BoolFlag{
|
||||
Name: "errors, e",
|
||||
Usage: "Only show errors",
|
||||
Destination: &OnlyErrors,
|
||||
}
|
||||
|
||||
var SkipUpdates bool
|
||||
var SkipUpdatesFlag = &cli.BoolFlag{
|
||||
Name: "skip-updates, s",
|
||||
Usage: "Skip updating recipe repositories",
|
||||
Destination: &SkipUpdates,
|
||||
}
|
||||
|
||||
var AllTags bool
|
||||
var AllTagsFlag = &cli.BoolFlag{
|
||||
Name: "all-tags, a",
|
||||
Usage: "List all tags, not just upgrades",
|
||||
Destination: &AllTags,
|
||||
}
|
||||
|
||||
// SSHFailMsg is a hopefully helpful SSH failure message
|
||||
var SSHFailMsg = `
|
||||
Woops, Abra is unable to connect to connect to %s.
|
||||
|
||||
Here are a few tips for debugging your local SSH config. Abra uses plain 'ol
|
||||
SSH to make connections to servers, so if your SSH config is working, Abra is
|
||||
working.
|
||||
|
||||
In the first place, Abra will always try to read your Docker context connection
|
||||
string for SSH connection details. You can view your server context configs
|
||||
with the following command. Are they correct?
|
||||
|
||||
abra server ls
|
||||
|
||||
Is your ssh-agent running? You can start it by running the following command:
|
||||
|
||||
eval "$(ssh-agent)"
|
||||
|
||||
If your SSH private key loaded? You can check by running the following command:
|
||||
|
||||
ssh-add -L
|
||||
|
||||
If, you can add it with:
|
||||
|
||||
ssh-add ~/.ssh/<private-key-part>
|
||||
|
||||
If you are using a non-default public/private key, you can configure this in
|
||||
your ~/.ssh/config file which Abra will read in order to figure out connection
|
||||
details:
|
||||
|
||||
Host foo.coopcloud.tech
|
||||
Hostname foo.coopcloud.tech
|
||||
User bar
|
||||
Port 12345
|
||||
IdentityFile ~/.ssh/bar@foo.coopcloud.tech
|
||||
|
||||
If you're only using password authentication, you can use the following config:
|
||||
|
||||
Host foo.coopcloud.tech
|
||||
Hostname foo.coopcloud.tech
|
||||
User bar
|
||||
Port 12345
|
||||
PreferredAuthentications=password
|
||||
PubkeyAuthentication=no
|
||||
|
||||
Good luck!
|
||||
|
||||
`
|
||||
|
||||
var ServerAddFailMsg = `
|
||||
Failed to add server %s.
|
||||
|
||||
This could be caused by two things.
|
||||
|
||||
Abra isn't picking up your SSH configuration or you need to specify it on the
|
||||
command-line (e.g you use a non-standard port or username to connect). Run
|
||||
"server add" with "-d/--debug" to learn more about what Abra is doing under the
|
||||
hood.
|
||||
|
||||
Docker is not installed on your server. You can pass "-p/--provision" to
|
||||
install Docker and initialise Docker Swarm mode. See help output for "server
|
||||
add"
|
||||
|
||||
See "abra server add -h" for more.
|
||||
|
||||
`
|
||||
|
||||
// SubCommandBefore wires up pre-action machinery (e.g. --debug handling).
|
||||
func SubCommandBefore(c *cli.Context) error {
|
||||
if Debug {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
logrus.SetFormatter(&logrus.TextFormatter{})
|
||||
logrus.SetOutput(os.Stderr)
|
||||
logrus.AddHook(logrusStack.StandardHook())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,76 +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,
|
||||
Required: true,
|
||||
}
|
266
cli/internal/deploy.go
Normal file
266
cli/internal/deploy.go
Normal file
@ -0,0 +1,266 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// DeployAction is the main command-line action for this package
|
||||
func DeployAction(c *cli.Context) error {
|
||||
app := ValidateApp(c)
|
||||
|
||||
if !Chaos {
|
||||
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
r, err := recipe.Get(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := lint.LintForErrors(r); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("checking whether %s is already deployed", app.StackName())
|
||||
|
||||
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if isDeployed {
|
||||
if Force || Chaos {
|
||||
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name)
|
||||
} else {
|
||||
logrus.Fatalf("%s is already deployed", app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
version := deployedVersion
|
||||
if version == "unknown" && !Chaos {
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if len(versions) > 0 {
|
||||
version = versions[len(versions)-1]
|
||||
logrus.Debugf("choosing %s as version to deploy", version)
|
||||
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
head, err := git.GetRecipeHead(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
version = formatter.SmallSHA(head.String())
|
||||
logrus.Warn("no versions detected, using latest commit")
|
||||
if err := recipe.EnsureLatest(app.Recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version == "unknown" && !Chaos {
|
||||
logrus.Debugf("choosing %s as version to deploy", version)
|
||||
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if version != "unknown" && !Chaos {
|
||||
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if Chaos {
|
||||
logrus.Warnf("chaos mode engaged")
|
||||
var err error
|
||||
version, err = recipe.ChaosVersion(app.Recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "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.Recipe, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: app.StackName(),
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
}
|
||||
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := DeployOverview(app, version, "continue with deployment?"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !NoDomainChecks {
|
||||
domainName := app.Env["DOMAIN"]
|
||||
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
logrus.Warn("skipping domain checks as requested")
|
||||
}
|
||||
|
||||
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, DontWaitConverge); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployOverview shows a deployment overview
|
||||
func DeployOverview(app config.App, version, message string) error {
|
||||
tableCol := []string{"server", "recipe", "config", "domain", "version"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
}
|
||||
|
||||
server := app.Server
|
||||
if app.Server == "default" {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, version})
|
||||
table.Render()
|
||||
|
||||
if NoInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewVersionOverview shows an upgrade or downgrade overview
|
||||
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error {
|
||||
tableCol := []string{"server", "recipe", "config", "domain", "current version", "to be deployed"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
deployConfig := "compose.yml"
|
||||
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
|
||||
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
|
||||
}
|
||||
|
||||
server := app.Server
|
||||
if app.Server == "default" {
|
||||
server = "local"
|
||||
}
|
||||
|
||||
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, currentVersion, newVersion})
|
||||
table.Render()
|
||||
|
||||
if releaseNotes == "" {
|
||||
var err error
|
||||
releaseNotes, err = GetReleaseNotes(app.Recipe, newVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if releaseNotes != "" && newVersion != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(fmt.Sprintf("%s release notes:\n\n%s", newVersion, releaseNotes))
|
||||
} else {
|
||||
logrus.Warnf("no release notes available for %s", newVersion)
|
||||
}
|
||||
|
||||
if NoInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with deployment?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReleaseNotes prints release notes for a recipe version
|
||||
func GetReleaseNotes(recipeName, version string) (string, error) {
|
||||
if version == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fpath := path.Join(config.RECIPES_DIR, recipeName, "release", version)
|
||||
|
||||
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
|
||||
releaseNotes, err := ioutil.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(releaseNotes), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
@ -4,7 +4,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// ShowSubcommandHelpAndError exits the program on error, logs the error to the
|
||||
|
@ -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)
|
||||
// }
|
10
cli/internal/list.go
Normal file
10
cli/internal/list.go
Normal file
@ -0,0 +1,10 @@
|
||||
package internal
|
||||
|
||||
// ReverseStringList reverses a list of a strings. Roll on Go generics.
|
||||
func ReverseStringList(strings []string) []string {
|
||||
for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 {
|
||||
strings[i], strings[j] = strings[j], strings[i]
|
||||
}
|
||||
|
||||
return strings
|
||||
}
|
196
cli/internal/new.go
Normal file
196
cli/internal/new.go
Normal file
@ -0,0 +1,196 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/secret"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// AppSecrets represents all app secrest
|
||||
type AppSecrets map[string]string
|
||||
|
||||
// RecipeName is used for configuring recipe name programmatically
|
||||
var RecipeName string
|
||||
|
||||
// createSecrets creates all secrets for a new app.
|
||||
func createSecrets(sanitisedAppName string) (AppSecrets, error) {
|
||||
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", Domain))
|
||||
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, Domain, 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
|
||||
}
|
||||
|
||||
// promptForSecrets asks if we should generate secrets for a new app.
|
||||
func promptForSecrets(appName string) error {
|
||||
app, err := app.Get(appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secretEnvVars := secret.ReadSecretEnvVars(app.Env)
|
||||
if len(secretEnvVars) == 0 {
|
||||
logrus.Debugf("%s has no secrets to generate, skipping...", app.Recipe)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !Secrets && !NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "Generate app secrets?",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &Secrets); 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 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
|
||||
}
|
||||
|
||||
// NewAction is the new app creation logic
|
||||
func NewAction(c *cli.Context) error {
|
||||
recipe := ValidateRecipeWithPrompt(c, false)
|
||||
|
||||
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureServerFlag(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDomainFlag(recipe, NewAppServer); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
sanitisedAppName := config.SanitiseAppName(Domain)
|
||||
logrus.Debugf("%s sanitised as %s for new app", Domain, sanitisedAppName)
|
||||
|
||||
if err := config.TemplateAppEnvSample(recipe.Name, Domain, NewAppServer, Domain); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promptForSecrets(Domain); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var secrets AppSecrets
|
||||
var secretTable *tablewriter.Table
|
||||
if Secrets {
|
||||
if err := ssh.EnsureHostKey(NewAppServer); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
secrets, err = createSecrets(sanitisedAppName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
secretCols := []string{"Name", "Value"}
|
||||
secretTable = formatter.CreateTable(secretCols)
|
||||
for secret := range secrets {
|
||||
secretTable.Append([]string{secret, secrets[secret]})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if NewAppServer == "default" {
|
||||
NewAppServer = "local"
|
||||
}
|
||||
|
||||
tableCol := []string{"server", "recipe", "domain"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table.Append([]string{NewAppServer, recipe.Name, Domain})
|
||||
|
||||
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", Domain))
|
||||
fmt.Println("")
|
||||
fmt.Println("You can deploy this app by running the following:")
|
||||
fmt.Println(fmt.Sprintf("\n abra app deploy %s", Domain))
|
||||
fmt.Println("")
|
||||
|
||||
if len(secrets) > 0 {
|
||||
fmt.Println("Here are your generated secrets:")
|
||||
fmt.Println("")
|
||||
secretTable.Render()
|
||||
fmt.Println("")
|
||||
logrus.Warn("generated secrets are not shown again, please take note of them *now*")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
110
cli/internal/recipe.go
Normal file
110
cli/internal/recipe.go
Normal file
@ -0,0 +1,110 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// PromptBumpType prompts for version bump type
|
||||
func PromptBumpType(tagString, latestRelease string) error {
|
||||
if (!Major && !Minor && !Patch) && tagString == "" {
|
||||
fmt.Printf(`
|
||||
You need to make a decision about what kind of an update this new recipe
|
||||
version is. If someone else performs this upgrade, do they have to do some
|
||||
migration work or take care of some breaking changes? This can be signaled in
|
||||
the version you specify on the recipe deploy label and is called a semantic
|
||||
version.
|
||||
|
||||
The latest published version is %s.
|
||||
|
||||
Here is a semver cheat sheet (more on https://semver.org):
|
||||
|
||||
major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).
|
||||
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
|
||||
prompt := &survey.Select{
|
||||
Message: fmt.Sprintf("select recipe version increment type"),
|
||||
Options: []string{"major", "minor", "patch"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &chosenBumpType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetBumpType(chosenBumpType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBumpType figures out which bump type is specified
|
||||
func GetBumpType() string {
|
||||
var bumpType string
|
||||
|
||||
if Major {
|
||||
bumpType = "major"
|
||||
} else if Minor {
|
||||
bumpType = "minor"
|
||||
} else if Patch {
|
||||
bumpType = "patch"
|
||||
} else {
|
||||
logrus.Fatal("no version bump type specififed?")
|
||||
}
|
||||
|
||||
return bumpType
|
||||
}
|
||||
|
||||
// SetBumpType figures out which bump type is specified
|
||||
func SetBumpType(bumpType string) {
|
||||
if bumpType == "major" {
|
||||
Major = true
|
||||
} else if bumpType == "minor" {
|
||||
Minor = true
|
||||
} else if bumpType == "patch" {
|
||||
Patch = true
|
||||
} else {
|
||||
logrus.Fatal("no version bump type specififed?")
|
||||
}
|
||||
}
|
||||
|
||||
// GetMainAppImage retrieves the main 'app' image name
|
||||
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
|
||||
var path string
|
||||
|
||||
for _, service := range recipe.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 path == "" {
|
||||
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
@ -2,36 +2,129 @@ package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/app"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// AppName is used for configuring app name programmatically
|
||||
var AppName string
|
||||
|
||||
// ValidateRecipe ensures the recipe arg is valid.
|
||||
func ValidateRecipe(c *cli.Context) recipe.Recipe {
|
||||
func ValidateRecipe(c *cli.Context, ensureLatest bool) recipe.Recipe {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
||||
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
}
|
||||
|
||||
recipe, err := recipe.Get(recipeName)
|
||||
chosenRecipe, err := recipe.Get(recipeName)
|
||||
if err != nil {
|
||||
if c.Command.Name == "generate" {
|
||||
if strings.Contains(err.Error(), "missing a compose") {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Warn(err)
|
||||
} else {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if ensureLatest {
|
||||
if err := recipe.EnsureLatest(recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as recipe argument", recipeName)
|
||||
|
||||
return chosenRecipe
|
||||
}
|
||||
|
||||
// ValidateRecipeWithPrompt ensures a recipe argument is present before
|
||||
// validating, asking for input if required.
|
||||
func ValidateRecipeWithPrompt(c *cli.Context, ensureLatest bool) recipe.Recipe {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" && !NoInput {
|
||||
var recipes []string
|
||||
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.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 {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
for _, recipeLocal := range localRecipes {
|
||||
if _, ok := knownRecipes[recipeLocal]; !ok {
|
||||
knownRecipes[recipeLocal] = true
|
||||
}
|
||||
}
|
||||
|
||||
for recipeName := range knownRecipes {
|
||||
recipes = append(recipes, recipeName)
|
||||
}
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: "Select recipe",
|
||||
Options: recipes,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if RecipeName != "" {
|
||||
recipeName = RecipeName
|
||||
logrus.Debugf("programmatically setting recipe name to %s", recipeName)
|
||||
}
|
||||
|
||||
if recipeName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
}
|
||||
|
||||
chosenRecipe, err := recipe.Get(recipeName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if ensureLatest {
|
||||
if err := recipe.EnsureLatest(recipeName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as recipe argument", recipeName)
|
||||
|
||||
return chosenRecipe
|
||||
}
|
||||
|
||||
// ValidateApp ensures the app name arg is valid.
|
||||
func ValidateApp(c *cli.Context) config.App {
|
||||
appName := c.Args().First()
|
||||
|
||||
if AppName != "" {
|
||||
appName = AppName
|
||||
logrus.Debugf("programmatically setting app name to %s", appName)
|
||||
}
|
||||
|
||||
if appName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no app provided"))
|
||||
}
|
||||
@ -41,11 +134,15 @@ func ValidateApp(c *cli.Context) config.App {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := recipe.EnsureExists(app.Type); err != nil {
|
||||
if err := recipe.EnsureExists(app.Recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("validated '%s' as app argument", appName)
|
||||
if err := ssh.EnsureHostKey(app.Server); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as app argument", appName)
|
||||
|
||||
return app
|
||||
}
|
||||
@ -54,11 +151,368 @@ func ValidateApp(c *cli.Context) config.App {
|
||||
func ValidateDomain(c *cli.Context) string {
|
||||
domainName := c.Args().First()
|
||||
|
||||
if domainName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify a domain name",
|
||||
Default: "example.com",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if domainName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no domain provided"))
|
||||
}
|
||||
|
||||
logrus.Debugf("validated '%s' as domain argument", domainName)
|
||||
logrus.Debugf("validated %s as domain argument", domainName)
|
||||
|
||||
return domainName
|
||||
}
|
||||
|
||||
// ValidateSubCmdFlags ensures flag order conforms to correct order
|
||||
func ValidateSubCmdFlags(c *cli.Context) bool {
|
||||
for argIdx, arg := range c.Args() {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
for _, flag := range c.Args()[argIdx:] {
|
||||
if strings.HasPrefix(flag, "--") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateServer ensures the server name arg is valid.
|
||||
func ValidateServer(c *cli.Context) string {
|
||||
serverName := c.Args().First()
|
||||
|
||||
serverNames, err := config.ReadServerNames()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if serverName == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Specify a server name",
|
||||
Options: serverNames,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &serverName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, name := range serverNames {
|
||||
if name == serverName {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
ShowSubcommandHelpAndError(c, errors.New("server doesn't exist?"))
|
||||
}
|
||||
|
||||
if serverName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no server provided"))
|
||||
}
|
||||
|
||||
logrus.Debugf("validated %s as server argument", serverName)
|
||||
|
||||
return serverName
|
||||
}
|
||||
|
||||
// EnsureDNSProvider ensures a DNS provider is chosen.
|
||||
func EnsureDNSProvider() error {
|
||||
if DNSProvider == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select DNS provider",
|
||||
Options: []string{"gandi"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSProvider == "" {
|
||||
return fmt.Errorf("missing DNS provider?")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSTypeFlag ensures a DNS type flag is present.
|
||||
func EnsureDNSTypeFlag(c *cli.Context) error {
|
||||
if DNSType == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record type",
|
||||
Default: "A",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSType == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSNameFlag ensures a DNS name flag is present.
|
||||
func EnsureDNSNameFlag(c *cli.Context) error {
|
||||
if DNSName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record name",
|
||||
Default: "mysubdomain",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSName == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDNSValueFlag ensures a DNS value flag is present.
|
||||
func EnsureDNSValueFlag(c *cli.Context) error {
|
||||
if DNSValue == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify DNS record value",
|
||||
Default: "192.168.1.2",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &DNSValue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if DNSValue == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureZoneArgument ensures a zone argument is present.
|
||||
func EnsureZoneArgument(c *cli.Context) (string, error) {
|
||||
zone := c.Args().First()
|
||||
|
||||
if zone == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "Specify a domain name zone",
|
||||
Default: "example.com",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &zone); err != nil {
|
||||
return zone, err
|
||||
}
|
||||
}
|
||||
|
||||
if zone == "" {
|
||||
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
|
||||
}
|
||||
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
// EnsureServerProvider ensures a 3rd party server provider is chosen.
|
||||
func EnsureServerProvider() error {
|
||||
if ServerProvider == "" && !NoInput {
|
||||
prompt := &survey.Select{
|
||||
Message: "Select server provider",
|
||||
Options: []string{"capsul", "hetzner-cloud"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ServerProvider == "" {
|
||||
return fmt.Errorf("missing server provider?")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureNewCapsulVPSFlags ensure all flags are present.
|
||||
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
|
||||
if CapsulName == "" && !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul name",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul instance URL",
|
||||
Default: CapsulInstanceURL,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul type",
|
||||
Default: CapsulType,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul image",
|
||||
Default: CapsulImage,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
|
||||
var sshKeys string
|
||||
prompt := &survey.Input{
|
||||
Message: "specify capsul SSH keys (e.g. me@foo.com)",
|
||||
Default: "",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
CapsulSSHKeys = cli.StringSlice(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.StringSlice(strings.Split(sshKeys, ","))
|
||||
}
|
||||
|
||||
if !NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS location",
|
||||
Default: HetznerCloudLocation,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if HetznerCloudAPIToken == "" && !NoInput {
|
||||
token, ok := os.LookupEnv("HCLOUD_TOKEN")
|
||||
if !ok {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud API token",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
HetznerCloudAPIToken = token
|
||||
}
|
||||
}
|
||||
|
||||
if HetznerCloudName == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
|
||||
}
|
||||
if HetznerCloudType == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
|
||||
}
|
||||
if HetznerCloudImage == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
|
||||
}
|
||||
if HetznerCloudLocation == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
|
||||
}
|
||||
if HetznerCloudAPIToken == "" {
|
||||
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -2,105 +2,78 @@ package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/docker/distribution/reference"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/lint"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeLintCommand = &cli.Command{
|
||||
var recipeLintCommand = cli.Command{
|
||||
Name: "lint",
|
||||
Usage: "Lint a recipe",
|
||||
Aliases: []string{"l"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
|
||||
expectedVersion := false
|
||||
if recipe.Config.Version == "3.8" {
|
||||
expectedVersion = true
|
||||
}
|
||||
|
||||
envSampleProvided := false
|
||||
envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe.Name)
|
||||
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
|
||||
envSampleProvided = true
|
||||
} else if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
serviceNamedApp := false
|
||||
traefikEnabled := false
|
||||
healthChecksForAllServices := true
|
||||
allImagesTagged := true
|
||||
noUnstableTags := true
|
||||
semverLikeTags := true
|
||||
for _, service := range recipe.Config.Services {
|
||||
if service.Name == "app" {
|
||||
serviceNamedApp = true
|
||||
}
|
||||
|
||||
for label := range service.Deploy.Labels {
|
||||
if label == "traefik.enable" {
|
||||
if service.Deploy.Labels[label] == "true" {
|
||||
traefikEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if reference.IsNameOnly(img) {
|
||||
allImagesTagged = false
|
||||
}
|
||||
|
||||
tag := img.(reference.NamedTagged).Tag()
|
||||
if tag == "latest" {
|
||||
noUnstableTags = false
|
||||
}
|
||||
|
||||
if !tagcmp.IsParsable(tag) {
|
||||
semverLikeTags = false
|
||||
}
|
||||
|
||||
if service.HealthCheck == nil {
|
||||
healthChecksForAllServices = false
|
||||
}
|
||||
}
|
||||
|
||||
tableCol := []string{"rule", "satisfied"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
table.Append([]string{"compose files have the expected version", strconv.FormatBool(expectedVersion)})
|
||||
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
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.OnlyErrorFlag,
|
||||
},
|
||||
BashComplete: func(c *cli.Context) {
|
||||
catl, err := catalogue.ReadRecipeCatalogue()
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c, true)
|
||||
|
||||
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
hasError := false
|
||||
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
|
||||
for level := range lint.LintRules {
|
||||
for _, rule := range lint.LintRules[level] {
|
||||
ok, err := rule.Function(recipe)
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
}
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
|
||||
if !ok && rule.Level == "error" {
|
||||
hasError = true
|
||||
}
|
||||
for name := range catl {
|
||||
fmt.Println(name)
|
||||
|
||||
var result string
|
||||
if ok {
|
||||
result = "yes"
|
||||
} else {
|
||||
result = "NO"
|
||||
}
|
||||
|
||||
if internal.OnlyErrors {
|
||||
if !ok && rule.Level == "error" {
|
||||
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
|
||||
bar.Add(1)
|
||||
}
|
||||
} else {
|
||||
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
|
||||
bar.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
fmt.Println()
|
||||
table.Render()
|
||||
}
|
||||
|
||||
if hasError {
|
||||
logrus.Warn("watch out, some critical errors are present in your recipe config")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
@ -3,36 +3,74 @@ package recipe
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeListCommand = &cli.Command{
|
||||
var pattern string
|
||||
var patternFlag = &cli.StringFlag{
|
||||
Name: "pattern, p",
|
||||
Value: "",
|
||||
Usage: "Simple string to filter recipes",
|
||||
Destination: &pattern,
|
||||
}
|
||||
|
||||
var recipeListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List available recipes",
|
||||
Aliases: []string{"ls"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
patternFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
catl, err := catalogue.ReadRecipeCatalogue()
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err.Error())
|
||||
}
|
||||
|
||||
recipes := catl.Flatten()
|
||||
sort.Sort(catalogue.ByRecipeName(recipes))
|
||||
sort.Sort(recipe.ByRecipeName(recipes))
|
||||
|
||||
tableCol := []string{"name", "category", "status"}
|
||||
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
len := 0
|
||||
for _, recipe := range recipes {
|
||||
status := fmt.Sprintf("%v", recipe.Features.Status)
|
||||
tableRow := []string{recipe.Name, recipe.Category, status}
|
||||
table.Append(tableRow)
|
||||
tableRow := []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.Append(tableRow)
|
||||
len++
|
||||
}
|
||||
} else {
|
||||
table.Append(tableRow)
|
||||
len++
|
||||
}
|
||||
}
|
||||
|
||||
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
|
@ -1,7 +1,10 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"text/template"
|
||||
@ -10,70 +13,128 @@ import (
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeNewCommand = &cli.Command{
|
||||
Name: "new",
|
||||
Usage: "Create a new recipe",
|
||||
Aliases: []string{"n"},
|
||||
ArgsUsage: "<recipe>",
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
// recipeMetadata is the recipe metadata for the README.md
|
||||
type recipeMetadata struct {
|
||||
Name string
|
||||
Description string
|
||||
Category string
|
||||
Status string
|
||||
Image string
|
||||
Healthcheck string
|
||||
Backups string
|
||||
Email string
|
||||
Tests string
|
||||
SSO string
|
||||
}
|
||||
|
||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
||||
var recipeNewCommand = cli.Command{
|
||||
Name: "new",
|
||||
Aliases: []string{"n"},
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Usage: "Create a new recipe",
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: `
|
||||
This command creates a new recipe.
|
||||
|
||||
Abra uses our built-in example repository which is available here:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/example
|
||||
|
||||
Files within the example repository make use of the Golang templating system
|
||||
which Abra uses to inject values into the generated recipe folder (e.g. name of
|
||||
recipe and domain in the sample environment config).
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipeName := c.Args().First()
|
||||
|
||||
if recipeName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
|
||||
}
|
||||
|
||||
directory := path.Join(config.RECIPES_DIR, recipeName)
|
||||
if _, err := os.Stat(directory); !os.IsNotExist(err) {
|
||||
logrus.Fatalf("'%s' recipe directory already exists?", directory)
|
||||
return nil
|
||||
logrus.Fatalf("%s recipe directory already exists?", directory)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
|
||||
if err := git.Clone(directory, url); err != nil {
|
||||
return err
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
gitRepo := path.Join(config.APPS_DIR, recipe.Name, ".git")
|
||||
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git")
|
||||
if err := os.RemoveAll(gitRepo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
logrus.Debugf("removed git repo in '%s'", gitRepo)
|
||||
logrus.Debugf("removed example git repo in %s", gitRepo)
|
||||
|
||||
meta := newRecipeMeta(recipeName)
|
||||
|
||||
toParse := []string{
|
||||
path.Join(config.APPS_DIR, recipe.Name, "README.md"),
|
||||
path.Join(config.APPS_DIR, recipe.Name, ".env.sample"),
|
||||
path.Join(config.APPS_DIR, recipe.Name, ".drone.yml"),
|
||||
path.Join(config.RECIPES_DIR, recipeName, "README.md"),
|
||||
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"),
|
||||
}
|
||||
for _, path := range toParse {
|
||||
file, err := os.OpenFile(path, os.O_RDWR, 0755)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tpl, err := template.ParseFiles(path)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: ask for description and probably other things so that the
|
||||
// template repository is more "ready" to go than the current best-guess
|
||||
// mode of templating
|
||||
if err := tpl.Execute(file, struct {
|
||||
Name string
|
||||
Description string
|
||||
}{recipe.Name, "TODO"}); err != nil {
|
||||
var templated bytes.Buffer
|
||||
if err := tpl.Execute(&templated, meta); err != nil {
|
||||
logrus.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Infof(
|
||||
"new recipe '%s' created in %s, happy hacking!\n",
|
||||
recipe.Name, path.Join(config.APPS_DIR, recipe.Name),
|
||||
)
|
||||
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
newGitRepo := path.Join(config.RECIPES_DIR, recipeName)
|
||||
if err := git.Init(newGitRepo, true); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Your new %s recipe has been created in %s.
|
||||
|
||||
In order to share your recipe, you can upload it the git repository to:
|
||||
|
||||
https://git.coopcloud.tech/coop-cloud/%s
|
||||
|
||||
If you're not sure how to do that, come chat with us:
|
||||
|
||||
https://docs.coopcloud.tech/contact
|
||||
|
||||
See "abra recipe -h" for additional recipe maintainer commands.
|
||||
|
||||
Happy Hacking!
|
||||
|
||||
`, recipeName, path.Join(config.RECIPES_DIR, recipeName), recipeName))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +1,26 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var Major bool
|
||||
var MajorFlag = &cli.BoolFlag{
|
||||
Name: "major",
|
||||
Value: false,
|
||||
Aliases: []string{"ma", "x"},
|
||||
Destination: &Major,
|
||||
}
|
||||
|
||||
var Minor bool
|
||||
var MinorFlag = &cli.BoolFlag{
|
||||
Name: "minor",
|
||||
Value: false,
|
||||
Aliases: []string{"mi", "y"},
|
||||
Destination: &Minor,
|
||||
}
|
||||
|
||||
var Patch bool
|
||||
var PatchFlag = &cli.BoolFlag{
|
||||
Name: "patch",
|
||||
Value: false,
|
||||
Aliases: []string{"p", "z"},
|
||||
Destination: &Patch,
|
||||
}
|
||||
|
||||
// RecipeCommand defines all recipe related sub-commands.
|
||||
var RecipeCommand = &cli.Command{
|
||||
var RecipeCommand = cli.Command{
|
||||
Name: "recipe",
|
||||
Usage: "Manage recipes (for maintainers)",
|
||||
ArgsUsage: "<recipe>",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Manage recipes",
|
||||
ArgsUsage: "<recipe>",
|
||||
Description: `
|
||||
A recipe is a blueprint for an app. It is a bunch of configuration files which
|
||||
A recipe is a blueprint for an app. It is a bunch of config files which
|
||||
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
|
||||
Cloud community and you can use Abra to read them and create apps for you.
|
||||
|
||||
Anyone who uses a recipe can become a maintainer. Maintainers typically make
|
||||
sure the recipe is in good working order and the config upgraded in a timely
|
||||
manner. Abra supports convenient automation for recipe maintainenace, see the
|
||||
"abra recipe upgrade", "abra recipe sync" and "abra recipe release" commands.
|
||||
`,
|
||||
Subcommands: []*cli.Command{
|
||||
Subcommands: []cli.Command{
|
||||
recipeListCommand,
|
||||
recipeVersionCommand,
|
||||
recipeReleaseCommand,
|
||||
|
@ -7,313 +7,448 @@ import (
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var Push bool
|
||||
var PushFlag = &cli.BoolFlag{
|
||||
Name: "push",
|
||||
Value: false,
|
||||
Destination: &Push,
|
||||
}
|
||||
|
||||
var Dry bool
|
||||
var DryFlag = &cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
Value: false,
|
||||
Aliases: []string{"d"},
|
||||
Destination: &Dry,
|
||||
}
|
||||
|
||||
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: "add compose.yml to staging area and commit changes",
|
||||
Value: false,
|
||||
Aliases: []string{"c"},
|
||||
Destination: &Commit,
|
||||
}
|
||||
|
||||
var TagMessage string
|
||||
var TagMessageFlag = &cli.StringFlag{
|
||||
Name: "tag-comment",
|
||||
Usage: "tag comment. If not given, user will be asked for it",
|
||||
Aliases: []string{"t", "tm"},
|
||||
Destination: &TagMessage,
|
||||
}
|
||||
|
||||
var recipeReleaseCommand = &cli.Command{
|
||||
var recipeReleaseCommand = cli.Command{
|
||||
Name: "release",
|
||||
Usage: "tag a recipe",
|
||||
Aliases: []string{"rl"},
|
||||
ArgsUsage: "<recipe> [<tag>]",
|
||||
Usage: "Release a new recipe version",
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
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:
|
||||
This command is used to specify a new version of a recipe. These versions are
|
||||
then published on the Co-op Cloud recipe catalogue. These versions take the
|
||||
following form:
|
||||
|
||||
a.b.c+x.y.z
|
||||
|
||||
Where the "a.b.c" part is maintained as a semantic version of the recipe by the
|
||||
recipe maintainer. And the "x.y.z" part is the image tag of the recipe "app"
|
||||
service (the main container which contains the software to be used).
|
||||
Where the "a.b.c" part is a semantic version determined by the maintainer. And
|
||||
the "x.y.z" part is the image tag of the recipe "app" service (the main
|
||||
container which contains the software to be used).
|
||||
|
||||
We maintain a semantic versioning scheme ("a.b.c") alongside the libre app
|
||||
versioning scheme in order to maximise the chances that the nature of recipe
|
||||
updates are properly communicated.
|
||||
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
|
||||
recipe updates are properly communicated. I.e. developers of an app might
|
||||
publish a minor version but that might lead to changes in the recipe which are
|
||||
major and therefore require intervention while doing the upgrade work.
|
||||
|
||||
Abra does its best to read the "a.b.c" version scheme and communicate what
|
||||
action needs to be taken when performing different operations such as an update
|
||||
or a rollback of an app.
|
||||
Publish your new release to git.coopcloud.tech with "-p/--publish". This
|
||||
requires that you have permission to git push to these repositories and have
|
||||
your SSH keys configured on your account.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
DryFlag,
|
||||
PatchFlag,
|
||||
MinorFlag,
|
||||
MajorFlag,
|
||||
PushFlag,
|
||||
CommitFlag,
|
||||
CommitMessageFlag,
|
||||
TagMessageFlag,
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
internal.PublishFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
||||
tagstring := c.Args().Get(1)
|
||||
imagesTmp := getImageVersions(recipe)
|
||||
mainApp := getMainApp(recipe)
|
||||
recipe := internal.ValidateRecipeWithPrompt(c, false)
|
||||
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
mainApp, err := internal.GetMainAppImage(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
mainAppVersion := imagesTmp[mainApp]
|
||||
|
||||
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if mainAppVersion == "" {
|
||||
logrus.Fatal("main app version is empty?")
|
||||
logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||
}
|
||||
|
||||
if tagstring != "" {
|
||||
if _, err := tagcmp.Parse(tagstring); err != nil {
|
||||
logrus.Fatal("invalid tag specified")
|
||||
tagString := c.Args().Get(1)
|
||||
if tagString != "" {
|
||||
if _, err := tagcmp.Parse(tagString); err != nil {
|
||||
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||
}
|
||||
}
|
||||
|
||||
if TagMessage == "" {
|
||||
prompt := &survey.Input{
|
||||
Message: "tag message",
|
||||
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
|
||||
logrus.Fatal("cannot specify tag and bump type at the same time")
|
||||
}
|
||||
if err := survey.AskOne(prompt, &TagMessage); err != nil {
|
||||
|
||||
if tagString != "" {
|
||||
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var createTagOptions git.CreateTagOptions
|
||||
createTagOptions.Message = TagMessage
|
||||
|
||||
if Commit || (CommitMessage != "") {
|
||||
commitRepo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
commitWorktree, err := commitRepo.Worktree()
|
||||
tags, err := recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if CommitMessage == "" {
|
||||
prompt := &survey.Input{
|
||||
Message: "commit message",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &CommitMessage); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
err = commitWorktree.AddGlob("compose.**yml")
|
||||
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||
var err error
|
||||
tagString, err = getLabelVersion(recipe, false)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debug("staged compose.**yml for commit")
|
||||
|
||||
_, err = commitWorktree.Commit(CommitMessage, &git.CommitOptions{})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info("changes commited")
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
// bumpType is used to decide what part of the tag should be incremented
|
||||
bumpType := btoi(Major)*4 + btoi(Minor)*2 + btoi(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 tagstring != "" {
|
||||
if bumpType > 0 {
|
||||
logrus.Warn("user specified a version number and --major/--minor/--patch at the same time! using version number...")
|
||||
}
|
||||
tag, err := tagcmp.Parse(tagstring)
|
||||
if err != nil {
|
||||
if len(tags) > 0 {
|
||||
logrus.Warnf("previous git tags detected, assuming this is a new semver release")
|
||||
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
|
||||
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 Dry {
|
||||
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagstring, head.Hash()))
|
||||
return nil
|
||||
}
|
||||
|
||||
repo.CreateTag(tagstring, head.Hash(), &createTagOptions)
|
||||
logrus.Info(fmt.Sprintf("created tag %s at %s", tagstring, head.Hash()))
|
||||
if Push {
|
||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagstring))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// get the latest tag with its hash, name etc
|
||||
var lastGitTag tagcmp.Tag
|
||||
iter, err := repo.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
obj, err := repo.TagObject(ref.Hash())
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
|
||||
fmt.Println(lastGitTag)
|
||||
|
||||
newTag := lastGitTag
|
||||
var newTagString string
|
||||
if bumpType > 0 {
|
||||
if Patch {
|
||||
now, err := strconv.Atoi(newTag.Patch)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
newTag.Patch = strconv.Itoa(now + 1)
|
||||
} else if Minor {
|
||||
now, err := strconv.Atoi(newTag.Minor)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
newTag.Patch = "0"
|
||||
newTag.Minor = strconv.Itoa(now + 1)
|
||||
} else if 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)
|
||||
}
|
||||
} else {
|
||||
logrus.Fatal("we don't support automatic tag generation yet - specify a version or use one of: --major --minor --patch")
|
||||
}
|
||||
newTag.Metadata = mainAppVersion
|
||||
newTagString = newTag.String()
|
||||
if Dry {
|
||||
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newTagString, head.Hash()))
|
||||
return nil
|
||||
}
|
||||
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
|
||||
|
||||
repo.CreateTag(newTagString, head.Hash(), &createTagOptions)
|
||||
logrus.Info(fmt.Sprintf("created tag %s at %s", newTagString, head.Hash()))
|
||||
if Push {
|
||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
||||
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
|
||||
logrus.Fatal(cleanUpErr)
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newTagString))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func getImageVersions(recipe recipe.Recipe) map[string]string {
|
||||
|
||||
// getImageVersions retrieves image versions for a recipe
|
||||
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
||||
var services = make(map[string]string)
|
||||
|
||||
missingTag := false
|
||||
for _, service := range recipe.Config.Services {
|
||||
if service.Image == "" {
|
||||
continue
|
||||
}
|
||||
srv := strings.Split(service.Image, ":")
|
||||
services[srv[0]] = srv[1]
|
||||
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return services, err
|
||||
}
|
||||
|
||||
return services
|
||||
path := reference.Path(img)
|
||||
|
||||
path = formatter.StripTagMeta(path)
|
||||
|
||||
var tag string
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
tag = img.(reference.NamedTagged).Tag()
|
||||
case reference.Named:
|
||||
if service.Name == "app" {
|
||||
missingTag = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
func getMainApp(recipe recipe.Recipe) string {
|
||||
for _, service := range recipe.Config.Services {
|
||||
name := service.Name
|
||||
if name == "app" {
|
||||
return strings.Split(service.Image, ":")[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
services[path] = tag
|
||||
}
|
||||
|
||||
if missingTag {
|
||||
return services, fmt.Errorf("app service is missing image tag?")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
directory := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
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 := commitRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := tagRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pushRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// btoi converts a boolean value into an integer
|
||||
func btoi(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func commitRelease(recipe recipe.Recipe, tag string) error {
|
||||
if internal.Dry {
|
||||
logrus.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)
|
||||
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
if err := gitPkg.Commit(repoPath, ".", msg, internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tagRelease(tagString string, repo *git.Repository) error {
|
||||
if internal.Dry {
|
||||
logrus.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())
|
||||
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRelease(recipe recipe.Recipe, tagString string) error {
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changes published")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !internal.Publish && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "publish new release?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Publish); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Publish {
|
||||
if err := recipe.Push(internal.Dry); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
|
||||
logrus.Infof("new release published: %s", url)
|
||||
} else {
|
||||
logrus.Info("no -p/--publish passed, not publishing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
|
||||
directory := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
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 {
|
||||
logrus.Fatalf("latest git tag (%s) and synced lable (%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 {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
if err := commitRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := tagRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pushRelease(recipe, tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanUpTag removes a freshly created tag
|
||||
func cleanUpTag(tag, recipeName string) error {
|
||||
directory := path.Join(config.RECIPES_DIR, recipeName)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo.DeleteTag(tag); err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("removed freshly created tag %s", tag)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
|
||||
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if initTag == "" {
|
||||
logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
|
||||
}
|
||||
|
||||
logrus.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
|
||||
}
|
||||
|
@ -1,88 +1,199 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeSyncCommand = &cli.Command{
|
||||
var recipeSyncCommand = cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Ensure recipe version labels are up-to-date",
|
||||
Aliases: []string{"s"},
|
||||
ArgsUsage: "<recipe> <version>",
|
||||
Usage: "Sync recipe version label",
|
||||
ArgsUsage: "<recipe> [<version>]",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DryFlag,
|
||||
internal.MajorFlag,
|
||||
internal.MinorFlag,
|
||||
internal.PatchFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
This command will generate labels for the main recipe service (i.e. by
|
||||
convention, typically the service named "app") which corresponds to the
|
||||
following format:
|
||||
convention, the service named 'app') which corresponds to the following format:
|
||||
|
||||
coop-cloud.${STACK_NAME}.version=<version>
|
||||
|
||||
The <version> is determined by the recipe maintainer and is specified on the
|
||||
command-line. The <recipe> configuration will be updated on the local file
|
||||
system.
|
||||
Where <version> can be specifed on the command-line or Abra can attempt to
|
||||
auto-generate it for you. The <recipe> configuration will be updated on the
|
||||
local file system.
|
||||
`,
|
||||
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)
|
||||
}
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.Args().Len() != 2 {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("missing <recipe>/<version> arguments?"))
|
||||
recipe := internal.ValidateRecipeWithPrompt(c, false)
|
||||
|
||||
mainApp, err := internal.GetMainAppImage(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
mainAppVersion := imagesTmp[mainApp]
|
||||
|
||||
tags, err := recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
// TODO: validate with tagcmp when new commits come in
|
||||
// See https://git.coopcloud.tech/coop-cloud/abra/pulls/109
|
||||
nextTag := c.Args().Get(1)
|
||||
if len(tags) == 0 && nextTag == "" {
|
||||
logrus.Warnf("no git tags found for %s", recipe.Name)
|
||||
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
|
||||
edPrompt := &survey.Select{
|
||||
Message: "which version do you want to begin with?",
|
||||
Options: []string{"0.1.0", "1.0.0"},
|
||||
}
|
||||
|
||||
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
|
||||
}
|
||||
|
||||
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
||||
latestRelease := tags[len(tags)-1]
|
||||
if err := internal.PromptBumpType("", latestRelease); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if nextTag == "" {
|
||||
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(recipeDir)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var lastGitTag tagcmp.Tag
|
||||
iter, err := repo.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
obj, err := repo.TagObject(ref.Hash())
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
|
||||
// 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 version flag: --major, --minor or --patch")
|
||||
}
|
||||
}
|
||||
|
||||
newTag := lastGitTag
|
||||
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
|
||||
logrus.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
|
||||
nextTag = newTag.String()
|
||||
}
|
||||
|
||||
if _, err := tagcmp.Parse(nextTag); err != nil {
|
||||
logrus.Fatalf("invalid version %s specified", nextTag)
|
||||
}
|
||||
|
||||
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.Warnf("no 'app' service defined in '%s'", recipe.Name)
|
||||
var chosenService string
|
||||
prompt := &survey.Select{
|
||||
Message: fmt.Sprintf("what is the main service name for '%s'?", recipe.Name),
|
||||
Options: services,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &chosenService); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
mainService = chosenService
|
||||
}
|
||||
|
||||
logrus.Debugf("selecting '%s' as the service to sync version labels", mainService)
|
||||
|
||||
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
|
||||
if err := recipe.UpdateLabel(mainService, label); err != nil {
|
||||
if !internal.Dry {
|
||||
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("synced label '%s' to service '%s'", label, mainService)
|
||||
} else {
|
||||
logrus.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
}
|
||||
|
@ -1,24 +1,35 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeUpgradeCommand = &cli.Command{
|
||||
type imgPin struct {
|
||||
image string
|
||||
version tagcmp.Tag
|
||||
}
|
||||
|
||||
var recipeUpgradeCommand = cli.Command{
|
||||
Name: "upgrade",
|
||||
Usage: "Upgrade recipe image tags",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Upgrade recipe image tags",
|
||||
Description: `
|
||||
This command reads and attempts to parse all image tags within the given
|
||||
<recipe> configuration and prompt with more recent tags to upgrade to. It will
|
||||
@ -27,17 +38,30 @@ update the relevant compose file tags on the local file system.
|
||||
Some image tags cannot be parsed because they do not follow some sort of
|
||||
semver-like convention. In this case, all possible tags will be listed and it
|
||||
is up to the end-user to decide.
|
||||
|
||||
The command is interactive and will show a select input which allows you to
|
||||
make a seclection. Use the "?" key to see more help on navigating this
|
||||
interface.
|
||||
|
||||
You may invoke this command in "wizard" mode and be prompted for input:
|
||||
|
||||
abra recipe upgrade
|
||||
`,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{
|
||||
PatchFlag,
|
||||
MinorFlag,
|
||||
MajorFlag,
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.PatchFlag,
|
||||
internal.MinorFlag,
|
||||
internal.MajorFlag,
|
||||
internal.AllTagsFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
recipe := internal.ValidateRecipeWithPrompt(c, true)
|
||||
|
||||
bumpType := btoi(Major)*4 + btoi(Minor)*2 + btoi(Patch)
|
||||
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 {
|
||||
@ -45,46 +69,79 @@ is up to the end-user to decide.
|
||||
}
|
||||
}
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
|
||||
// check for versions file and load pinned versions
|
||||
versionsPresent := false
|
||||
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
|
||||
versionsPath := path.Join(recipeDir, "versions")
|
||||
var servicePins = make(map[string]imgPin)
|
||||
if _, err := os.Stat(versionsPath); err == nil {
|
||||
logrus.Debugf("found versions file for %s", recipe.Name)
|
||||
file, err := os.Open(versionsPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
splitLine := strings.Split(line, " ")
|
||||
if splitLine[0] != "pin" || len(splitLine) != 3 {
|
||||
logrus.Fatalf("malformed version pin specification: %s", line)
|
||||
}
|
||||
pinSlice := strings.Split(splitLine[2], ":")
|
||||
pinTag, err := tagcmp.Parse(pinSlice[1])
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
pin := imgPin{
|
||||
image: pinSlice[0],
|
||||
version: pinTag,
|
||||
}
|
||||
servicePins[splitLine[1]] = pin
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
versionsPresent = true
|
||||
} else {
|
||||
logrus.Debugf("did not find versions file for %s", recipe.Name)
|
||||
}
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
image := reference.Path(img)
|
||||
regVersions, err := client.GetRegistryTags(image)
|
||||
regVersions, err := client.GetRegistryTags(img)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("retrieved '%s' from remote registry for '%s'", regVersions, image)
|
||||
|
||||
if strings.Contains(image, "library") {
|
||||
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
|
||||
// postgres:<tag>, i.e. images which do not have a username in the
|
||||
// first position of the string
|
||||
image = strings.Split(image, "/")[1]
|
||||
}
|
||||
image := reference.Path(img)
|
||||
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
|
||||
image = formatter.StripTagMeta(image)
|
||||
|
||||
semverLikeTag := true
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
|
||||
logrus.Debugf("'%s' not considered semver-like", img.(reference.NamedTagged).Tag())
|
||||
semverLikeTag = false
|
||||
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
|
||||
}
|
||||
default:
|
||||
logrus.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
|
||||
if err != nil && semverLikeTag {
|
||||
logrus.Fatal(err)
|
||||
if err != nil {
|
||||
logrus.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)
|
||||
|
||||
logrus.Debugf("parsed %s for %s", tag, service.Name)
|
||||
|
||||
var compatible []tagcmp.Tag
|
||||
for _, regVersion := range regVersions {
|
||||
other, err := tagcmp.Parse(regVersion.Name)
|
||||
other, err := tagcmp.Parse(regVersion)
|
||||
if err != nil {
|
||||
continue // skip tags that cannot be parsed
|
||||
}
|
||||
@ -94,16 +151,21 @@ is up to the end-user to decide.
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("detected potential upgradable tags '%s' for '%s'", compatible, service.Name)
|
||||
logrus.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)
|
||||
|
||||
sort.Sort(tagcmp.ByTagDesc(compatible))
|
||||
|
||||
if len(compatible) == 0 && semverLikeTag {
|
||||
logrus.Info(fmt.Sprintf("no new versions available for '%s', '%s' is the latest", image, tag))
|
||||
if len(compatible) == 0 && !internal.AllTags {
|
||||
logrus.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
|
||||
}
|
||||
|
||||
var compatibleStrings []string
|
||||
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
compatibleStrings := []string{"skip"}
|
||||
for _, compat := range compatible {
|
||||
skip := false
|
||||
for _, catlVersion := range catlVersions {
|
||||
@ -116,8 +178,33 @@ is up to the end-user to decide.
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("detected compatible upgradable tags '%s' for '%s'", compatibleStrings, service.Name)
|
||||
logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
|
||||
|
||||
var upgradeTag string
|
||||
_, ok := servicePins[service.Name]
|
||||
if versionsPresent && ok {
|
||||
pinnedTag := servicePins[service.Name].version
|
||||
if tag.IsLessThan(pinnedTag) {
|
||||
pinnedTagString := pinnedTag.String()
|
||||
contains := false
|
||||
for _, v := range compatible {
|
||||
if pinnedTag.IsUpgradeCompatible(v) {
|
||||
contains = true
|
||||
upgradeTag = v.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
if contains {
|
||||
logrus.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
|
||||
} else {
|
||||
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
logrus.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if bumpType != 0 {
|
||||
for _, upTag := range compatible {
|
||||
upElement, err := tag.UpgradeDelta(upTag)
|
||||
@ -131,34 +218,45 @@ is up to the end-user to decide.
|
||||
}
|
||||
}
|
||||
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)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
|
||||
if !tagcmp.IsParsable(img.(reference.NamedTagged).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()) || internal.AllTags {
|
||||
tag := img.(reference.NamedTagged).Tag()
|
||||
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of '%s', listing all tags", tag))
|
||||
if !internal.AllTags {
|
||||
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
|
||||
}
|
||||
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
|
||||
compatibleStrings = []string{}
|
||||
compatibleStrings = []string{"skip"}
|
||||
for _, regVersion := range regVersions {
|
||||
compatibleStrings = append(compatibleStrings, regVersion.Name)
|
||||
compatibleStrings = append(compatibleStrings, regVersion)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := &survey.Select{
|
||||
Message: msg,
|
||||
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
|
||||
VimMode: true,
|
||||
Options: compatibleStrings,
|
||||
}
|
||||
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := recipe.UpdateTag(image, upgradeTag); err != nil {
|
||||
}
|
||||
if upgradeTag != "skip" {
|
||||
ok, err := recipe.UpdateTag(image, upgradeTag)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Infof("tag upgraded from '%s' to '%s' for '%s'", tag.String(), upgradeTag, image)
|
||||
if ok {
|
||||
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
|
||||
}
|
||||
} else {
|
||||
logrus.Warnf("not upgrading %s, skipping as requested", image)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -1,36 +1,42 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/catalogue"
|
||||
"coopcloud.tech/abra/pkg/autocomplete"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var recipeVersionCommand = &cli.Command{
|
||||
var recipeVersionCommand = cli.Command{
|
||||
Name: "versions",
|
||||
Usage: "List recipe versions",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "List recipe versions",
|
||||
ArgsUsage: "<recipe>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipe(c)
|
||||
recipe := internal.ValidateRecipe(c, false)
|
||||
|
||||
catalogue, err := catalogue.ReadRecipeCatalogue()
|
||||
catalogue, err := recipePkg.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
recipeMeta, ok := catalogue[recipe.Name]
|
||||
if !ok {
|
||||
logrus.Fatalf("'%s' recipe doesn't exist?", recipe.Name)
|
||||
logrus.Fatalf("%s recipe doesn't exist?", recipe.Name)
|
||||
}
|
||||
|
||||
tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, serviceVersion := range recipeMeta.Versions {
|
||||
for tag, meta := range serviceVersion {
|
||||
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
|
||||
for tag, meta := range recipeMeta.Versions[i] {
|
||||
for service, serviceMeta := range meta {
|
||||
table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest})
|
||||
}
|
||||
@ -38,7 +44,12 @@ var recipeVersionCommand = &cli.Command{
|
||||
}
|
||||
|
||||
table.SetAutoMergeCells(true)
|
||||
|
||||
if table.NumLines() > 0 {
|
||||
table.Render()
|
||||
} else {
|
||||
logrus.Fatalf("%s has no published versions?", recipe.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
|
@ -1,40 +1,46 @@
|
||||
package domain
|
||||
package record
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
abraFormatter "coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// DomainListCommand lists domains.
|
||||
var DomainListCommand = &cli.Command{
|
||||
// RecordListCommand lists domains.
|
||||
var RecordListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List domain name records for a zone",
|
||||
Usage: "List domain name records",
|
||||
Aliases: []string{"ls"},
|
||||
ArgsUsage: "<zone>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.DNSProviderFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Description: `
|
||||
This command lists all domain name records managed by a 3rd party provider.
|
||||
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 {
|
||||
zone := c.Args().First()
|
||||
if zone == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no zone provided"))
|
||||
if err := internal.EnsureDNSProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
zone, err := internal.EnsureZoneArgument(c)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
var provider gandi.Provider
|
||||
switch internal.DNSProvider {
|
||||
case "gandi":
|
||||
@ -43,16 +49,16 @@ are listed. This zone must already be created on your provider account.
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
default:
|
||||
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
|
||||
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider)
|
||||
}
|
||||
|
||||
records, err := provider.GetRecords(c.Context, zone)
|
||||
records, err := provider.GetRecords(context.Background(), zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := abraFormatter.CreateTable(tableCol)
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
for _, record := range records {
|
||||
value := record.Value
|
148
cli/record/new.go
Normal file
148
cli/record/new.go
Normal file
@ -0,0 +1,148 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// 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.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DNSProviderFlag,
|
||||
internal.DNSTypeFlag,
|
||||
internal.DNSNameFlag,
|
||||
internal.DNSValueFlag,
|
||||
internal.DNSTTLFlag,
|
||||
internal.DNSPriorityFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
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)
|
||||
}
|
||||
|
||||
ttl, err := dns.GetTTL(internal.DNSTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := libdns.Record{
|
||||
Type: internal.DNSType,
|
||||
Name: internal.DNSName,
|
||||
Value: internal.DNSValue,
|
||||
TTL: ttl,
|
||||
}
|
||||
|
||||
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
|
||||
record.Priority = internal.DNSPriority
|
||||
}
|
||||
|
||||
records, err := provider.GetRecords(context.Background(), zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
for _, existingRecord := range records {
|
||||
if existingRecord.Type == record.Type &&
|
||||
existingRecord.Name == record.Name &&
|
||||
existingRecord.Value == record.Value {
|
||||
logrus.Fatalf("%s record for %s already exists?", record.Type, zone)
|
||||
}
|
||||
}
|
||||
|
||||
createdRecords, err := provider.SetRecords(
|
||||
context.Background(),
|
||||
zone,
|
||||
[]libdns.Record{record},
|
||||
)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(createdRecords) == 0 {
|
||||
logrus.Fatal("provider library reports that no record was created?")
|
||||
}
|
||||
|
||||
createdRecord := createdRecords[0]
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
value := createdRecord.Value
|
||||
if len(createdRecord.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", createdRecord.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
createdRecord.Type,
|
||||
createdRecord.Name,
|
||||
value,
|
||||
createdRecord.TTL.String(),
|
||||
strconv.Itoa(createdRecord.Priority),
|
||||
})
|
||||
|
||||
table.Render()
|
||||
|
||||
logrus.Info("record created")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
package domain
|
||||
package record
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// DomainCommand supports managing DNS entries.
|
||||
var DomainCommand = &cli.Command{
|
||||
Name: "domain",
|
||||
Usage: "Manage domains via 3rd party providers",
|
||||
Aliases: []string{"d"},
|
||||
ArgsUsage: "<domain>",
|
||||
// RecordCommand supports managing DNS entries.
|
||||
var RecordCommand = cli.Command{
|
||||
Name: "record",
|
||||
Usage: "Manage domain name records",
|
||||
Aliases: []string{"rc"},
|
||||
ArgsUsage: "<record>",
|
||||
Description: `
|
||||
This command supports managing domain name records via 3rd party providers such
|
||||
as Gandi DNS. It supports listing, creating and removing all types of records
|
||||
@ -23,16 +23,15 @@ 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.
|
||||
|
||||
Any new provider 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.
|
||||
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{
|
||||
DomainListCommand,
|
||||
DomainCreateCommand,
|
||||
DomainRemoveCommand,
|
||||
Subcommands: []cli.Command{
|
||||
RecordListCommand,
|
||||
RecordNewCommand,
|
||||
RecordRemoveCommand,
|
||||
},
|
||||
}
|
136
cli/record/remove.go
Normal file
136
cli/record/remove.go
Normal file
@ -0,0 +1,136 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/libdns/gandi"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// RecordRemoveCommand lists domains.
|
||||
var RecordRemoveCommand = cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove a domain name record",
|
||||
Aliases: []string{"rm"},
|
||||
ArgsUsage: "<zone>",
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
internal.DNSProviderFlag,
|
||||
internal.DNSTypeFlag,
|
||||
internal.DNSNameFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
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(context.Background(), zone)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
var toDelete libdns.Record
|
||||
for _, record := range records {
|
||||
if record.Type == internal.DNSType && record.Name == internal.DNSName {
|
||||
toDelete = record
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (libdns.Record{}) == toDelete {
|
||||
logrus.Fatal("provider library reports no matching record?")
|
||||
}
|
||||
|
||||
tableCol := []string{"type", "name", "value", "TTL", "priority"}
|
||||
table := formatter.CreateTable(tableCol)
|
||||
|
||||
value := toDelete.Value
|
||||
if len(toDelete.Value) > 30 {
|
||||
value = fmt.Sprintf("%s...", toDelete.Value[:30])
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
toDelete.Type,
|
||||
toDelete.Name,
|
||||
value,
|
||||
toDelete.TTL.String(),
|
||||
strconv.Itoa(toDelete.Priority),
|
||||
})
|
||||
|
||||
table.Render()
|
||||
|
||||
if !internal.NoInput {
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with record deletion?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
}
|
||||
|
||||
_, err = provider.DeleteRecords(context.Background(), zone, []libdns.Record{toDelete})
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Info("record successfully deleted")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
@ -3,149 +3,509 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/dns"
|
||||
"coopcloud.tech/abra/pkg/server"
|
||||
"coopcloud.tech/abra/pkg/ssh"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
dockerClient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
dockerInstallMsg = `
|
||||
A docker installation cannot be found on %s. This is a required system
|
||||
dependency for running Co-op Cloud on your server. If you would like, Abra can
|
||||
attempt to install Docker for you using the upstream non-interactive
|
||||
installation script.
|
||||
|
||||
See the following documentation for more:
|
||||
|
||||
https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script
|
||||
|
||||
N.B Docker doesn't recommend it for production environments but many use it for
|
||||
such purposes. Docker stable is now installed by default by this script. The
|
||||
source for this script can be seen here:
|
||||
|
||||
https://github.com/docker/docker-install
|
||||
`
|
||||
)
|
||||
|
||||
var local bool
|
||||
var localFlag = &cli.BoolFlag{
|
||||
Name: "local",
|
||||
Aliases: []string{"L"},
|
||||
Value: false,
|
||||
Usage: "Set up the local server",
|
||||
Name: "local, l",
|
||||
Usage: "Use local server",
|
||||
Destination: &local,
|
||||
}
|
||||
|
||||
var serverAddCommand = &cli.Command{
|
||||
Name: "add",
|
||||
Usage: "Add a new server",
|
||||
Description: `
|
||||
This command adds a new server that abra will communicate with, to deploy apps.
|
||||
|
||||
If "--local" is passed, then Abra assumes that the current local server is
|
||||
intended as the target server.
|
||||
|
||||
Otherwise, you may specify a remote server. The <domain> argument must be a
|
||||
publicy accessible domain name which points to your server. You should have SSH
|
||||
access to this server, Abra will assume port 22 and will use your current
|
||||
system username to make an initial connection. You can use the <user> and
|
||||
<port> arguments to adjust this.
|
||||
|
||||
For example:
|
||||
|
||||
abra server add varia.zone glodemodem 12345
|
||||
|
||||
Abra will construct the following SSH connection string then:
|
||||
|
||||
ssh://globemodem@varia.zone:12345
|
||||
|
||||
All communication between Abra and the server will use this SSH connection.
|
||||
`,
|
||||
Aliases: []string{"a"},
|
||||
Flags: []cli.Flag{
|
||||
localFlag,
|
||||
},
|
||||
ArgsUsage: "<domain> [<user>] [<port>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
|
||||
if c.Args().Len() == 2 && !local {
|
||||
err := errors.New("missing arguments <domain> or '--local'")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
var provision bool
|
||||
var provisionFlag = &cli.BoolFlag{
|
||||
Name: "provision, p",
|
||||
Usage: "Provision server so it can deploy apps",
|
||||
Destination: &provision,
|
||||
}
|
||||
|
||||
if c.Args().Get(2) != "" && local {
|
||||
err := errors.New("cannot use '<domain>' and '--local' together")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
var sshAuth string
|
||||
var sshAuthFlag = &cli.StringFlag{
|
||||
Name: "ssh-auth, s",
|
||||
Value: "identity-file",
|
||||
Usage: "Select SSH authentication method (identity-file, password)",
|
||||
Destination: &sshAuth,
|
||||
}
|
||||
|
||||
domainName := "default"
|
||||
var askSudoPass bool
|
||||
var askSudoPassFlag = &cli.BoolFlag{
|
||||
Name: "ask-sudo-pass, a",
|
||||
Usage: "Ask for sudo password",
|
||||
Destination: &askSudoPass,
|
||||
}
|
||||
|
||||
if local {
|
||||
if err := server.CreateServerDir(domainName); err != nil {
|
||||
func cleanUp(domainName string) {
|
||||
logrus.Warnf("cleaning up context for %s", domainName)
|
||||
if err := client.DeleteContext(domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return errors.New("docker command not found on $PATH, is it installed?")
|
||||
|
||||
logrus.Warnf("cleaning up server directory for %s", domainName)
|
||||
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, domainName)); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info("local server has been added")
|
||||
}
|
||||
|
||||
func installDockerLocal(c *cli.Context) error {
|
||||
fmt.Println(fmt.Sprintf(dockerInstallMsg, "this local server"))
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("attempt install docker on local server?"),
|
||||
}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
for _, exe := range []string{"wget", "bash"} {
|
||||
exists, err := ensureLocalExecutable(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s missing, please install it", exe)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", "-c", "wget -O- https://get.docker.com | bash")
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
domainName = internal.ValidateDomain(c)
|
||||
func newLocalServer(c *cli.Context, domainName string) error {
|
||||
if err := createServerDir(domainName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var username string
|
||||
var port string
|
||||
|
||||
username = c.Args().Get(1)
|
||||
if username == "" {
|
||||
systemUser, err := user.Current()
|
||||
cl, err := newClient(c, domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if provision {
|
||||
exists, err := ensureLocalExecutable("docker")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err := installDockerLocal(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := initSwarmLocal(c, cl, domainName); err != nil {
|
||||
if !strings.Contains(err.Error(), "proxy already exists") {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
username = systemUser.Username
|
||||
}
|
||||
}
|
||||
|
||||
port = c.Args().Get(2)
|
||||
if port == "" {
|
||||
port = "22"
|
||||
logrus.Info("local server has been added")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
store := client.NewDefaultDockerContextStore()
|
||||
func newContext(c *cli.Context, domainName, username, port string) error {
|
||||
store := contextPkg.NewDefaultDockerContextStore()
|
||||
contexts, err := store.Store.List()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, context := range contexts {
|
||||
if context.Name == domainName {
|
||||
logrus.Fatalf("server at '%s' already exists?", 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)
|
||||
logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port)
|
||||
|
||||
if err := client.CreateContext(domainName, username, port); err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return nil
|
||||
}
|
||||
|
||||
func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) {
|
||||
cl, err := client.New(domainName)
|
||||
if err != nil {
|
||||
logrus.Warn("cleaning up context due to connection failure")
|
||||
if err := client.DeleteContext(domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
return &dockerClient.Client{}, err
|
||||
}
|
||||
logrus.Fatal(err)
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
if _, err := cl.Info(ctx); err != nil {
|
||||
if strings.Contains(err.Error(), "command not found") {
|
||||
logrus.Fatalf("docker is not installed on '%s'?", domainName)
|
||||
func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error {
|
||||
exists, err := ensureRemoteExecutable("docker", sshCl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
fmt.Println(fmt.Sprintf(dockerInstallMsg, domainName))
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("attempt install docker on %s?", domainName),
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
exes := []string{"wget", "bash"}
|
||||
if askSudoPass {
|
||||
exes = append(exes, "ssh-askpass")
|
||||
}
|
||||
|
||||
for _, exe := range exes {
|
||||
exists, err := ensureRemoteExecutable(exe, sshCl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s missing on remote, please install it", exe)
|
||||
}
|
||||
}
|
||||
|
||||
var sudoPass string
|
||||
if askSudoPass {
|
||||
cmd := "wget -O- https://get.docker.com | bash"
|
||||
|
||||
prompt := &survey.Password{
|
||||
Message: "sudo password?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &sudoPass); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("running %s on %s now with sudo password", cmd, domainName)
|
||||
|
||||
if sudoPass == "" {
|
||||
return fmt.Errorf("missing sudo password but requested --ask-sudo-pass?")
|
||||
}
|
||||
|
||||
logrus.Warn("installing docker, this could take some time...")
|
||||
|
||||
if err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Abra was unable to bootstrap Docker, see below for logs:
|
||||
|
||||
|
||||
%s
|
||||
|
||||
If nothing works, you try running the Docker install script manually on your server:
|
||||
|
||||
wget -O- https://get.docker.com | bash
|
||||
|
||||
`, string(err.Error())))
|
||||
logrus.Fatal("Process exited with status 1")
|
||||
}
|
||||
|
||||
logrus.Infof("docker is installed on %s", domainName)
|
||||
|
||||
remoteUser := sshCl.SSHClient.Conn.User()
|
||||
logrus.Infof("adding %s to docker group", remoteUser)
|
||||
permsCmd := fmt.Sprintf("sudo usermod -aG docker %s", remoteUser)
|
||||
if err := ssh.RunSudoCmd(permsCmd, sudoPass, sshCl); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logrus.Warn("cleaning up context due to connection failure")
|
||||
if err := client.DeleteContext(domainName); err != nil {
|
||||
cmd := "wget -O- https://get.docker.com | bash"
|
||||
|
||||
logrus.Debugf("running %s on %s now without sudo password", cmd, domainName)
|
||||
|
||||
logrus.Warn("installing docker, this could take some time...")
|
||||
|
||||
if out, err := sshCl.Exec(cmd); err != nil {
|
||||
fmt.Print(fmt.Sprintf(`
|
||||
Abra was unable to bootstrap Docker, see below for logs:
|
||||
|
||||
|
||||
%s
|
||||
|
||||
This could be due to a number of things but one of the most common is that your
|
||||
server user account does not have sudo access, and if it does, you need to pass
|
||||
"--ask-sudo-pass" in order to supply Abra with your password.
|
||||
|
||||
If nothing works, you try running the Docker install script manually on your server:
|
||||
|
||||
wget -O- https://get.docker.com | bash
|
||||
|
||||
`, string(out)))
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Fatalf("unable to make a connection to '%s'?", domainName)
|
||||
|
||||
logrus.Infof("docker is installed on %s", domainName)
|
||||
}
|
||||
logrus.Debug(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("remote connection to '%s' is definitely up", 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(context.Background(), initReq); err != nil {
|
||||
if strings.Contains(err.Error(), "is already part of a swarm") ||
|
||||
strings.Contains(err.Error(), "must specify a listening address") {
|
||||
logrus.Infof("swarm mode already initialised on %s", domainName)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("initialised swarm mode on local server")
|
||||
}
|
||||
|
||||
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
|
||||
if _, err := cl.NetworkCreate(context.Background(), "proxy", netOpts); err != nil {
|
||||
if !strings.Contains(err.Error(), "proxy already exists") {
|
||||
return err
|
||||
}
|
||||
logrus.Info("swarm overlay network already created on local server")
|
||||
} else {
|
||||
logrus.Infof("swarm overlay network created on local server")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error {
|
||||
ipv4, err := dns.EnsureIPv4(domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initReq := swarm.InitRequest{
|
||||
ListenAddr: "0.0.0.0:2377",
|
||||
AdvertiseAddr: ipv4,
|
||||
}
|
||||
if _, err := cl.SwarmInit(context.Background(), initReq); err != nil {
|
||||
if strings.Contains(err.Error(), "is already part of a swarm") ||
|
||||
strings.Contains(err.Error(), "must specify a listening address") {
|
||||
logrus.Infof("swarm mode already initialised on %s", domainName)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("initialised swarm mode on %s", domainName)
|
||||
}
|
||||
|
||||
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
|
||||
if _, err := cl.NetworkCreate(context.Background(), "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
|
||||
}
|
||||
|
||||
var serverAddCommand = cli.Command{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
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 command can also provision your server ("--provision/-p") with a
|
||||
Docker installation 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.
|
||||
|
||||
The <domain> argument must be a publicy accessible domain name which points to
|
||||
your server. You should working SSH access to this server already, Abra will
|
||||
assume port 22 and will use your current system username to make an initial
|
||||
connection. You can use the <user> and <port> arguments to adjust this.
|
||||
|
||||
Example:
|
||||
|
||||
abra server add varia.zone glodemodem 12345 -p
|
||||
|
||||
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.
|
||||
|
||||
If "--local" is passed, then Abra assumes that the current local server is
|
||||
intended as the target server. This is useful when you want to have your entire
|
||||
Co-op Cloud config located on the server itself, and not on your local
|
||||
developer machine.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
localFlag,
|
||||
provisionFlag,
|
||||
sshAuthFlag,
|
||||
askSudoPassFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
ArgsUsage: "<domain> [<user>] [<port>]",
|
||||
Action: func(c *cli.Context) error {
|
||||
if len(c.Args()) > 0 && local || !internal.ValidateSubCmdFlags(c) {
|
||||
err := errors.New("cannot use <domain> and --local together")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
|
||||
if sshAuth != "password" && sshAuth != "identity-file" {
|
||||
err := errors.New("--ssh-auth only accepts identity-file or password")
|
||||
internal.ShowSubcommandHelpAndError(c, err)
|
||||
}
|
||||
|
||||
domainName := internal.ValidateDomain(c)
|
||||
|
||||
if local {
|
||||
if err := newLocalServer(c, "default"); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
username := c.Args().Get(1)
|
||||
if username == "" {
|
||||
systemUser, err := user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username = systemUser.Username
|
||||
}
|
||||
|
||||
port := c.Args().Get(2)
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
if err := createServerDir(domainName); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("server at '%s' has been added", domainName)
|
||||
if err := newContext(c, domainName, username, port); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := newClient(c, domainName)
|
||||
if err != nil {
|
||||
cleanUp(domainName)
|
||||
logrus.Debugf("failed to construct client for %s, saw %s", domainName, err.Error())
|
||||
logrus.Fatalf(fmt.Sprintf(internal.ServerAddFailMsg, domainName))
|
||||
}
|
||||
|
||||
if provision {
|
||||
logrus.Debugf("attempting to construct SSH client for %s", domainName)
|
||||
sshCl, err := ssh.New(domainName, sshAuth, username, port)
|
||||
if err != nil {
|
||||
cleanUp(domainName)
|
||||
logrus.Fatalf(fmt.Sprintf(internal.ServerAddFailMsg, domainName))
|
||||
}
|
||||
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(context.Background()); err != nil {
|
||||
cleanUp(domainName)
|
||||
logrus.Fatalf(fmt.Sprintf(internal.ServerAddFailMsg, domainName))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ensureLocalExecutable ensures that an executable is present on the local machine
|
||||
func ensureLocalExecutable(exe string) (bool, error) {
|
||||
out, err := exec.Command("which", exe).Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return string(out) != "", nil
|
||||
}
|
||||
|
||||
// ensureRemoteExecutable ensures that an executable is present on a remote machine
|
||||
func ensureRemoteExecutable(exe string, sshCl *ssh.Client) (bool, error) {
|
||||
out, err := sshCl.Exec(fmt.Sprintf("which %s", exe))
|
||||
if err != nil && string(out) != "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return string(out) != "", nil
|
||||
}
|
||||
|
@ -1,81 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var serverInitCommand = &cli.Command{
|
||||
Name: "init",
|
||||
Usage: "Initialise server for deploying apps",
|
||||
Aliases: []string{"i"},
|
||||
HideHelp: true,
|
||||
ArgsUsage: "<domain>",
|
||||
Description: `
|
||||
Initialise swarm mode on the target <domain>.
|
||||
|
||||
This initialisation explicitly chooses the "single host swarm" mode which uses
|
||||
the default IPv4 address as the advertising address. This can be re-configured
|
||||
later for more advanced use cases.
|
||||
`,
|
||||
Action: func(c *cli.Context) error {
|
||||
domainName := internal.ValidateDomain(c)
|
||||
|
||||
cl, err := client.New(domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
// comrade librehosters DNS resolver https://snopyta.org/service/dns/
|
||||
return d.DialContext(ctx, "udp", freifunkDNS)
|
||||
},
|
||||
}
|
||||
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
|
||||
|
||||
ctx := context.Background()
|
||||
ips, err := resolver.LookupIPAddr(ctx, domainName)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
|
||||
}
|
||||
ipv4 := ips[0].IP.To4().String()
|
||||
|
||||
initReq := swarm.InitRequest{
|
||||
ListenAddr: "0.0.0.0:2377",
|
||||
AdvertiseAddr: ipv4,
|
||||
}
|
||||
if _, err := cl.SwarmInit(ctx, initReq); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("initialised swarm on '%s'", domainName)
|
||||
|
||||
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
|
||||
if _, err := cl.NetworkCreate(ctx, "proxy", netOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debug("swarm overlay network 'proxy' created")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
@ -3,27 +3,31 @@ package server
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/context"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/docker/cli/cli/connhelper/ssh"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var serverListCommand = &cli.Command{
|
||||
var serverListCommand = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List managed servers",
|
||||
ArgsUsage: " ",
|
||||
HideHelp: true,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
dockerContextStore := client.NewDefaultDockerContextStore()
|
||||
dockerContextStore := context.NewDefaultDockerContextStore()
|
||||
contexts, err := dockerContextStore.Store.List()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tableColumns := []string{"Name", "Connection"}
|
||||
tableColumns := []string{"name", "host", "user", "port"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
defer table.Render()
|
||||
|
||||
@ -35,20 +39,24 @@ var serverListCommand = &cli.Command{
|
||||
for _, serverName := range serverNames {
|
||||
var row []string
|
||||
for _, ctx := range contexts {
|
||||
endpoint, err := client.GetContextEndpoint(ctx)
|
||||
endpoint, err := context.GetContextEndpoint(ctx)
|
||||
if err != nil && strings.Contains(err.Error(), "does not exist") {
|
||||
// No local context found, we can continue safely
|
||||
continue
|
||||
}
|
||||
if ctx.Name == serverName {
|
||||
row = []string{serverName, endpoint}
|
||||
sp, err := ssh.ParseURL(endpoint)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
row = []string{serverName, sp.Host, sp.User, sp.Port}
|
||||
}
|
||||
}
|
||||
if len(row) == 0 {
|
||||
if serverName == "default" {
|
||||
row = []string{serverName, "local"}
|
||||
row = []string{serverName, "local", "n/a", "n/a"}
|
||||
} else {
|
||||
row = []string{serverName, "unknown"}
|
||||
row = []string{serverName, "unknown", "unknown", "unknown"}
|
||||
}
|
||||
}
|
||||
table.Append(row)
|
||||
|
@ -2,219 +2,261 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/libcapsul"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var hetznerCloudType string
|
||||
var hetznerCloudImage string
|
||||
var hetznerCloudSSHKeys cli.StringSlice
|
||||
var hetznerCloudLocation string
|
||||
var hetznerCloudAPIToken string
|
||||
var serverNewHetznerCloudCommand = &cli.Command{
|
||||
Name: "hetzner",
|
||||
Usage: "Create a new Hetzner virtual server",
|
||||
ArgsUsage: "<name>",
|
||||
Description: `
|
||||
Create a new Hetzner virtual server.
|
||||
|
||||
This command uses the uses the Hetzner Cloud API bindings to send a server
|
||||
creation request. You must already have a Hetzner Cloud account and an account
|
||||
API token before using this command.
|
||||
|
||||
Your token can be loaded from the environment using the HCLOUD_TOKEN
|
||||
environment variable or otherwise passing the "--env/-e" flag.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Server type",
|
||||
Destination: &hetznerCloudType,
|
||||
Value: "cx11",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "image",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Image type",
|
||||
Value: "debian-10",
|
||||
Destination: &hetznerCloudImage,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "ssh-keys",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "SSH keys",
|
||||
Destination: &hetznerCloudSSHKeys,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "location",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Server location",
|
||||
Value: "hel1",
|
||||
Destination: &hetznerCloudLocation,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"T"},
|
||||
Usage: "Hetzner Cloud API token",
|
||||
EnvVars: []string{"HCLOUD_TOKEN"},
|
||||
Destination: &hetznerCloudAPIToken,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
|
||||
func newHetznerCloudVPS(c *cli.Context) error {
|
||||
if err := internal.EnsureNewHetznerCloudVPSFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hetznerCloudAPIToken == "" {
|
||||
logrus.Fatal("Hetzner Cloud API token is missing")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client := hcloud.NewClient(hcloud.WithToken(hetznerCloudAPIToken))
|
||||
|
||||
logrus.Debugf("successfully created hetzner cloud API client")
|
||||
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
|
||||
|
||||
var sshKeysRaw []string
|
||||
var sshKeys []*hcloud.SSHKey
|
||||
for _, sshKey := range c.StringSlice("ssh-keys") {
|
||||
sshKey, _, err := client.SSHKey.GetByName(ctx, sshKey)
|
||||
for _, sshKey := range c.StringSlice("hetzner-ssh-keys") {
|
||||
if sshKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sshKey, _, err := client.SSHKey.GetByName(context.Background(), sshKey)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
sshKeys = append(sshKeys, sshKey)
|
||||
sshKeysRaw = append(sshKeysRaw, sshKey.Name)
|
||||
}
|
||||
|
||||
serverOpts := hcloud.ServerCreateOpts{
|
||||
Name: internal.HetznerCloudName,
|
||||
ServerType: &hcloud.ServerType{Name: internal.HetznerCloudType},
|
||||
Image: &hcloud.Image{Name: internal.HetznerCloudImage},
|
||||
SSHKeys: sshKeys,
|
||||
Location: &hcloud.Location{Name: internal.HetznerCloudLocation},
|
||||
}
|
||||
|
||||
sshKeyIDs := strings.Join(sshKeysRaw, "\n")
|
||||
if sshKeyIDs == "" {
|
||||
sshKeyIDs = "N/A (password auth)"
|
||||
}
|
||||
|
||||
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
table.Append([]string{
|
||||
internal.HetznerCloudName,
|
||||
internal.HetznerCloudType,
|
||||
internal.HetznerCloudImage,
|
||||
sshKeyIDs,
|
||||
internal.HetznerCloudLocation,
|
||||
})
|
||||
table.Render()
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with hetzner cloud VPS creation?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
res, _, err := client.Server.Create(context.Background(), serverOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rootPassword string
|
||||
if len(sshKeys) > 0 {
|
||||
rootPassword = "N/A (using SSH keys)"
|
||||
} else {
|
||||
rootPassword = res.RootPassword
|
||||
}
|
||||
|
||||
ip := res.Server.PublicNet.IPv4.IP.String()
|
||||
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
Your new Hetzner Cloud VPS has successfully been created! Here are the details:
|
||||
|
||||
name: %s
|
||||
IP address: %s
|
||||
root password: %s
|
||||
|
||||
You can access this new VPS via SSH using the following command:
|
||||
|
||||
ssh root@%s
|
||||
|
||||
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
|
||||
not list this server)! You will need to assign a domain name record ("abra
|
||||
record new") and add the server to your Abra configuration ("abra server add")
|
||||
to have a working server that you can deploy Co-op Cloud apps to.
|
||||
|
||||
When setting up domain name records, you probably want to set up the following
|
||||
2 A records. This supports deploying apps to your root domain (e.g.
|
||||
example.com) and other apps on sub-domains (e.g. foo.example.com,
|
||||
bar.example.com).
|
||||
|
||||
@ 1800 IN A %s
|
||||
* 1800 IN A %s
|
||||
|
||||
`,
|
||||
internal.HetznerCloudName, ip, rootPassword,
|
||||
ip, ip, ip,
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCapsulVPS(c *cli.Context) error {
|
||||
if err := internal.EnsureNewCapsulVPSFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", internal.CapsulInstanceURL)
|
||||
|
||||
var sshKeys []string
|
||||
for _, sshKey := range c.StringSlice("capsul-ssh-keys") {
|
||||
if sshKey == "" {
|
||||
continue
|
||||
}
|
||||
sshKeys = append(sshKeys, sshKey)
|
||||
}
|
||||
|
||||
serverOpts := hcloud.ServerCreateOpts{
|
||||
Name: name,
|
||||
ServerType: &hcloud.ServerType{Name: hetznerCloudType},
|
||||
Image: &hcloud.Image{Name: hetznerCloudImage},
|
||||
SSHKeys: sshKeys,
|
||||
Location: &hcloud.Location{Name: hetznerCloudLocation},
|
||||
}
|
||||
res, _, err := client.Server.Create(ctx, serverOpts)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Debugf("new server '%s' created", name)
|
||||
|
||||
tableColumns := []string{"Name", "IPv4", "Root Password"}
|
||||
tableColumns := []string{"instance", "name", "type", "image", "ssh-keys"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
|
||||
if len(sshKeys) > 0 {
|
||||
table.Append([]string{name, res.Server.PublicNet.IPv4.IP.String(), "N/A (using SSH keys)"})
|
||||
} else {
|
||||
table.Append([]string{name, res.Server.PublicNet.IPv4.IP.String(), res.RootPassword})
|
||||
}
|
||||
|
||||
table.Append([]string{
|
||||
internal.CapsulInstanceURL,
|
||||
internal.CapsulName,
|
||||
internal.CapsulType,
|
||||
internal.CapsulImage,
|
||||
strings.Join(sshKeys, "\n"),
|
||||
})
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "continue with capsul creation?",
|
||||
}
|
||||
|
||||
var capsulInstance string
|
||||
var capsulType string
|
||||
var capsulImage string
|
||||
var capsulSSHKey string
|
||||
var capsulAPIToken string
|
||||
var serverNewCapsulCommand = &cli.Command{
|
||||
Name: "capsul",
|
||||
Usage: "Create a new Capsul virtual server",
|
||||
ArgsUsage: "<name>",
|
||||
Description: `
|
||||
Create a new Capsul virtual server.
|
||||
|
||||
This command uses the uses the Capsul API bindings of your chosen instance to
|
||||
send a server creation request. You must already have an account on your chosen
|
||||
Capsul instance before using this command.
|
||||
|
||||
Your token can be loaded from the environment using the CAPSUL_TOKEN
|
||||
environment variable or otherwise passing the "--env/-e" flag.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "instance",
|
||||
Aliases: []string{"I"},
|
||||
Usage: "Capsul instance",
|
||||
Destination: &capsulInstance,
|
||||
Value: "yolo.servers.coop",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Server type",
|
||||
Value: "f1-xs",
|
||||
Destination: &capsulType,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "image",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Image type",
|
||||
Value: "debian10",
|
||||
Destination: &capsulImage,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ssh-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "SSH key",
|
||||
Value: "",
|
||||
Destination: &capsulSSHKey,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"T"},
|
||||
Usage: "Capsul instance API token",
|
||||
EnvVars: []string{"CAPSUL_TOKEN"},
|
||||
Destination: &capsulAPIToken,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
capsulName := c.Args().First()
|
||||
if capsulName == "" {
|
||||
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if capsulAPIToken == "" {
|
||||
logrus.Fatal("Capsul API token is missing")
|
||||
if !response {
|
||||
logrus.Fatal("exiting as requested")
|
||||
}
|
||||
|
||||
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", capsulInstance)
|
||||
|
||||
capsulClient := libcapsul.New(capsulCreateURL, capsulAPIToken)
|
||||
resp, err := capsulClient.Create(capsulName, capsulType, capsulImage, capsulSSHKey)
|
||||
capsulClient := libcapsul.New(capsulCreateURL, internal.CapsulAPIToken)
|
||||
resp, err := capsulClient.Create(
|
||||
internal.CapsulName,
|
||||
internal.CapsulType,
|
||||
internal.CapsulImage,
|
||||
sshKeys,
|
||||
)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tableColumns := []string{"Name", "ID"}
|
||||
table := formatter.CreateTable(tableColumns)
|
||||
table.Append([]string{capsulName, resp.ID})
|
||||
table.Render()
|
||||
fmt.Println(fmt.Sprintf(`
|
||||
Your new Capsul has successfully been created! Here are the details:
|
||||
|
||||
Capsul name: %s
|
||||
Capsul ID: %v
|
||||
|
||||
You will need to log into your Capsul instance web interface to retrieve the IP
|
||||
address. You can learn all about how to get SSH access to your new Capsul on:
|
||||
|
||||
%s/about-ssh
|
||||
|
||||
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
|
||||
not list this server)! You will need to assign a domain name record ("abra
|
||||
record new") and add the server to your Abra configuration ("abra server add")
|
||||
to have a working server that you can deploy Co-op Cloud apps to.
|
||||
|
||||
When setting up domain name records, you probably want to set up the following
|
||||
2 A records. This supports deploying apps to your root domain (e.g.
|
||||
example.com) and other apps on sub-domains (e.g. foo.example.com,
|
||||
bar.example.com).
|
||||
|
||||
@ 1800 IN A <your-capsul-ip>
|
||||
* 1800 IN A <your-capsul-ip>
|
||||
|
||||
`, internal.CapsulName, resp.ID, internal.CapsulInstanceURL))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var serverNewCommand = &cli.Command{
|
||||
var serverNewCommand = cli.Command{
|
||||
Name: "new",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Create a new server using a 3rd party provider",
|
||||
Description: `
|
||||
Use a provider plugin to create a new server which can then be used to house a
|
||||
new Co-op Cloud installation.
|
||||
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>",
|
||||
Subcommands: []*cli.Command{
|
||||
serverNewHetznerCloudCommand,
|
||||
serverNewCapsulCommand,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
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,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
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
|
||||
},
|
||||
}
|
||||
|
@ -1,29 +1,183 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var serverRemoveCommand = &cli.Command{
|
||||
var rmServer bool
|
||||
var rmServerFlag = &cli.BoolFlag{
|
||||
Name: "server, s",
|
||||
Usage: "remove the actual server also",
|
||||
Destination: &rmServer,
|
||||
}
|
||||
|
||||
func rmHetznerCloudVPS(c *cli.Context) error {
|
||||
if internal.HetznerCloudName == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud VPS name",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.HetznerCloudName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.HetznerCloudAPIToken == "" && !internal.NoInput {
|
||||
token, ok := os.LookupEnv("HCLOUD_TOKEN")
|
||||
if !ok {
|
||||
prompt := &survey.Input{
|
||||
Message: "specify hetzner cloud API token",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.HetznerCloudAPIToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
internal.HetznerCloudAPIToken = token
|
||||
}
|
||||
}
|
||||
|
||||
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
|
||||
|
||||
server, _, err := client.Server.Get(context.Background(), 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(context.Background(), 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"},
|
||||
Usage: "Remove a server",
|
||||
ArgsUsage: "[<server>]",
|
||||
Usage: "Remove a managed server",
|
||||
Description: `
|
||||
This does not destroy the actual server. It simply removes it from Abra
|
||||
internal bookkeeping so that it is not managed any more.
|
||||
`,
|
||||
HideHelp: true,
|
||||
Action: func(c *cli.Context) error {
|
||||
domainName := internal.ValidateDomain(c)
|
||||
This command removes a server from Abra management.
|
||||
|
||||
if err := client.DeleteContext(domainName); err != nil {
|
||||
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,
|
||||
like tears in rain.
|
||||
`,
|
||||
Flags: []cli.Flag{
|
||||
internal.DebugFlag,
|
||||
internal.NoInputFlag,
|
||||
rmServerFlag,
|
||||
internal.ServerProviderFlag,
|
||||
|
||||
// Hetzner
|
||||
internal.HetznerCloudNameFlag,
|
||||
internal.HetznerCloudAPITokenFlag,
|
||||
},
|
||||
Before: internal.SubCommandBefore,
|
||||
Action: func(c *cli.Context) error {
|
||||
serverName := internal.ValidateServer(c)
|
||||
|
||||
warnMsg := `Did not pass -s/--server for actual server deletion, prompting!
|
||||
|
||||
Abra doesn't currently know if it helped you create this server with one of the
|
||||
3rd party integrations (e.g. Capsul). You have a choice here to actually,
|
||||
really and finally destroy this server using those integrations. If you want to
|
||||
do this, choose Yes.
|
||||
|
||||
If you just want to remove the server config files & context, choose No.
|
||||
`
|
||||
|
||||
if !rmServer {
|
||||
logrus.Warn(fmt.Sprintf(warnMsg))
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "delete actual live server?",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
if response {
|
||||
logrus.Info("setting -s/--server and attempting to remove actual server")
|
||||
rmServer = true
|
||||
}
|
||||
}
|
||||
|
||||
if rmServer {
|
||||
if err := internal.EnsureServerProvider(); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("server at '%s' has been forgotten", domainName)
|
||||
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 {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName)
|
||||
|
||||
return nil
|
||||
},
|
||||
|
@ -1,24 +1,25 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// ServerCommand defines the `abra server` command and its subcommands
|
||||
var ServerCommand = &cli.Command{
|
||||
var ServerCommand = cli.Command{
|
||||
Name: "server",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Manage servers via 3rd party providers",
|
||||
Usage: "Manage servers",
|
||||
Description: `
|
||||
Manage the lifecycle of a server.
|
||||
These commands support creating, managing and removing servers using 3rd party
|
||||
integrations.
|
||||
|
||||
These commands support creating new servers using 3rd party integrations,
|
||||
initialising existing servers to support Co-op Cloud deployments and managing
|
||||
the connections to those servers.
|
||||
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{
|
||||
Subcommands: []cli.Command{
|
||||
serverNewCommand,
|
||||
serverInitCommand,
|
||||
serverAddCommand,
|
||||
serverListCommand,
|
||||
serverRemoveCommand,
|
||||
|
@ -1,23 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// UpgradeCommand upgrades abra in-place.
|
||||
var UpgradeCommand = &cli.Command{
|
||||
Name: "upgrade",
|
||||
Usage: "Upgrade abra",
|
||||
Action: func(c *cli.Context) error {
|
||||
cmd := exec.Command("bash", "-c", "curl -s https://install.abra.coopcloud.tech | bash")
|
||||
logrus.Debugf("attempting to run '%s'", cmd)
|
||||
if err := internal.RunCmd(cmd); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
@ -5,14 +5,13 @@ import (
|
||||
"coopcloud.tech/abra/cli"
|
||||
)
|
||||
|
||||
// Version is the current version of abra.
|
||||
// Version is the current version of Abra
|
||||
var Version string
|
||||
|
||||
// Commit is the current commit of abra.
|
||||
// Commit is the current git commit of Abra
|
||||
var Commit string
|
||||
|
||||
func main() {
|
||||
// If not set in the ld-flags
|
||||
if Version == "" {
|
||||
Version = "dev"
|
||||
}
|
||||
|
45
go.mod
45
go.mod
@ -3,42 +3,51 @@ module coopcloud.tech/abra
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
coopcloud.tech/tagcmp v0.0.0-20211011140827-4f27c74467eb
|
||||
github.com/AlecAivazis/survey/v2 v2.3.1
|
||||
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
|
||||
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
|
||||
github.com/AlecAivazis/survey/v2 v2.3.4
|
||||
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731094149-b031ea1211e7
|
||||
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
|
||||
github.com/docker/cli v20.10.8+incompatible
|
||||
github.com/docker/distribution v2.7.1+incompatible
|
||||
github.com/docker/docker v20.10.8+incompatible
|
||||
github.com/docker/cli v20.10.14+incompatible
|
||||
github.com/docker/distribution v2.8.1+incompatible
|
||||
github.com/docker/docker v20.10.14+incompatible
|
||||
github.com/docker/go-units v0.4.0
|
||||
github.com/go-git/go-git/v5 v5.4.2
|
||||
github.com/hetznercloud/hcloud-go v1.32.0
|
||||
github.com/moby/sys/signal v0.5.0
|
||||
github.com/hetznercloud/hcloud-go v1.33.1
|
||||
github.com/moby/sys/signal v0.7.0
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/schollz/progressbar/v3 v3.8.3
|
||||
github.com/schollz/progressbar/v3 v3.8.6
|
||||
github.com/schultz-is/passgen v1.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
gotest.tools/v3 v3.0.3
|
||||
gotest.tools/v3 v3.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
coopcloud.tech/libcapsul v0.0.0-20211020153234-f1386b5cf79d
|
||||
github.com/Microsoft/hcsshim v0.8.21 // indirect
|
||||
github.com/containerd/containerd v1.5.5 // indirect
|
||||
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3 // indirect
|
||||
github.com/buger/goterm v1.0.4
|
||||
github.com/containerd/containerd v1.5.9 // indirect
|
||||
github.com/containers/image v3.0.2+incompatible
|
||||
github.com/containers/storage v1.38.2 // indirect
|
||||
github.com/docker/docker-credential-helpers v0.6.4 // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/fvbommel/sortorder v1.0.2 // indirect
|
||||
github.com/gliderlabs/ssh v0.3.3
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.0
|
||||
github.com/kevinburke/ssh_config v1.1.0
|
||||
github.com/libdns/gandi v1.0.2
|
||||
github.com/libdns/libdns v0.2.1
|
||||
github.com/moby/sys/mount v0.2.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/runc v1.0.2 // indirect
|
||||
github.com/opencontainers/image-spec v1.0.3-0.20211202193544-a5463b7f9c84 // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/spf13/cobra v1.3.0 // indirect
|
||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
|
||||
github.com/urfave/cli v1.22.5
|
||||
github.com/xanzy/ssh-agent v0.3.1 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b // indirect
|
||||
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27
|
||||
)
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
apiclient "github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@ -23,7 +23,7 @@ func Get(appName string) (config.App, error) {
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved '%s' for '%s'", app, appName)
|
||||
logrus.Debugf("retrieved %s for %s", app, appName)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
@ -57,9 +57,9 @@ func DeployedVersions(ctx context.Context, cl *apiclient.Client, app config.App)
|
||||
deployed := len(services) > 0
|
||||
|
||||
if deployed {
|
||||
logrus.Debugf("detected '%s' as deployed versions of '%s'", appSpec, app.Name)
|
||||
logrus.Debugf("detected %s as deployed versions of %s", appSpec, app.Name)
|
||||
} else {
|
||||
logrus.Debugf("detected '%s' as not deployed", app.Name)
|
||||
logrus.Debugf("detected %s as not deployed", app.Name)
|
||||
}
|
||||
|
||||
return appSpec, len(services) > 0, nil
|
||||
@ -71,15 +71,15 @@ func ParseVersionLabel(label string) (string, string) {
|
||||
idx := strings.LastIndex(label, "-")
|
||||
version := label[:idx]
|
||||
digest := label[idx+1:]
|
||||
logrus.Debugf("parsed '%s' as version from '%s'", version, label)
|
||||
logrus.Debugf("parsed '%s' as digest from '%s'", digest, label)
|
||||
logrus.Debugf("parsed %s as version from %s", version, label)
|
||||
logrus.Debugf("parsed %s as digest from %s", digest, label)
|
||||
return version, digest
|
||||
}
|
||||
|
||||
// ParseVersionName parses a $STACK_NAME_$SERVICE_NAME service label.
|
||||
// ParseServiceName parses a $STACK_NAME_$SERVICE_NAME service label.
|
||||
func ParseServiceName(label string) string {
|
||||
idx := strings.LastIndex(label, "_")
|
||||
serviceName := label[idx+1:]
|
||||
logrus.Debugf("parsed '%s' as service name from '%s'", serviceName, label)
|
||||
logrus.Debugf("parsed %s as service name from %s", serviceName, label)
|
||||
return serviceName
|
||||
}
|
||||
|
63
pkg/autocomplete/autocomplete.go
Normal file
63
pkg/autocomplete/autocomplete.go
Normal file
@ -0,0 +1,63 @@
|
||||
package autocomplete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// AppNameComplete copletes app names
|
||||
func AppNameComplete(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)
|
||||
}
|
||||
}
|
||||
|
||||
// RecipeNameComplete completes recipe names
|
||||
func RecipeNameComplete(c *cli.Context) {
|
||||
catl, err := recipe.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
}
|
||||
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for name := range catl {
|
||||
fmt.Println(name)
|
||||
}
|
||||
}
|
||||
|
||||
// SubcommandComplete completes subcommands.
|
||||
func SubcommandComplete(c *cli.Context) {
|
||||
if c.NArg() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
subcmds := []string{
|
||||
"app",
|
||||
"autocomplete",
|
||||
"catalogue",
|
||||
"recipe",
|
||||
"record",
|
||||
"server",
|
||||
"upgrade",
|
||||
}
|
||||
|
||||
for _, cmd := range subcmds {
|
||||
fmt.Println(cmd)
|
||||
}
|
||||
}
|
@ -1,500 +0,0 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/abra/pkg/web"
|
||||
"github.com/docker/distribution/reference"
|
||||
"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.
|
||||
const RecipeCatalogueURL = "https://apps.coopcloud.tech"
|
||||
|
||||
// ReposMetadataURL is the recipe repository metadata
|
||||
const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// readRecipeCatalogueWeb reads the catalogue from the web.
|
||||
func readRecipeCatalogueWeb(target interface{}) error {
|
||||
if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recipesJSON, err := json.MarshalIndent(target, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("read recipe catalogue from web at '%s'", RecipeCatalogueURL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VersionsOfService lists the version of a service.
|
||||
func VersionsOfService(recipe, serviceName string) ([]string, error) {
|
||||
catalogue, err := ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec, ok := catalogue[recipe]
|
||||
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 {
|
||||
return RecipeMeta{}, err
|
||||
}
|
||||
|
||||
recipeMeta, ok := catl[recipeName]
|
||||
if !ok {
|
||||
err := fmt.Errorf("recipe '%s' does not exist?", recipeName)
|
||||
return RecipeMeta{}, err
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
gitTags, err := repo.Tags()
|
||||
if err != nil {
|
||||
return versions, err
|
||||
}
|
||||
|
||||
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
|
||||
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
|
||||
|
||||
logrus.Debugf("processing '%s' for '%s'", tag, recipeName)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
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)
|
||||
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
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
IMPORTANT POINT ABOUT CONTEXTS
|
||||
|
||||
Please use context names starting with `testContext` for testing purposes to ensure that no data is lost. such as `testContext`, `testContext2`, `testContextFail` etc
|
||||
|
@ -6,7 +6,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
contextPkg "coopcloud.tech/abra/pkg/context"
|
||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@ -15,24 +16,27 @@ import (
|
||||
func New(contextName string) (*client.Client, error) {
|
||||
var clientOpts []client.Opt
|
||||
|
||||
clientOpts = append(clientOpts, client.WithTimeout(3*time.Second))
|
||||
|
||||
if contextName != "default" {
|
||||
context, err := GetContext(contextName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctxEndpoint, err := GetContextEndpoint(context)
|
||||
ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
helper := newConnectionHelper(ctxEndpoint)
|
||||
httpClient := &http.Client{
|
||||
// No tls, no proxy
|
||||
Transport: &http.Transport{
|
||||
DialContext: helper.Dialer,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
@ -55,15 +59,7 @@ func New(contextName string) (*client.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.Debugf("created client for '%s'", contextName)
|
||||
logrus.Debugf("created client for %s", contextName)
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
func newConnectionHelper(daemonURL string) *connhelper.ConnectionHelper {
|
||||
helper, err := connhelper.GetConnectionHelper(daemonURL)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
return helper
|
||||
}
|
||||
|
@ -1,46 +0,0 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
)
|
||||
|
||||
// use at the start to ensure testContext[0, 1, ..., amnt-1] exist and
|
||||
// testContextFail[0, 1, ..., failAmnt-1] don't exist
|
||||
func ensureTestState(amnt, failAmnt int) error {
|
||||
for i := 0; i < amnt; i++ {
|
||||
err := client.CreateContext(fmt.Sprintf("testContext%d", i), "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := 0; i < failAmnt; i++ {
|
||||
if _, er := client.GetContext(fmt.Sprintf("testContextFail%d", i)); er == nil {
|
||||
err := client.DeleteContext(fmt.Sprintf("testContextFail%d", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
err := ensureTestState(1, 1)
|
||||
if err != nil {
|
||||
t.Errorf("Couldn't ensure existence/nonexistence of contexts: %s", err)
|
||||
}
|
||||
contextName := "testContext0"
|
||||
_, err = client.New(contextName)
|
||||
if err != nil {
|
||||
t.Errorf("couldn't initialise a new client with context %s: %s", contextName, err)
|
||||
}
|
||||
contextName = "testContextFail0"
|
||||
_, err = client.New(contextName)
|
||||
if err == nil {
|
||||
t.Errorf("client.New(\"testContextFail0\") should have failed but didn't return an error")
|
||||
}
|
||||
|
||||
}
|
@ -1,191 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2013-2017 Docker, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
@ -1,7 +0,0 @@
|
||||
# github.com/docker/cli/cli/command/container
|
||||
|
||||
Due to this literally just being copy-pasted from the lib, the Apache license
|
||||
will be posted in this folder. Small edits to the source code have been to
|
||||
function names and parts we don't need deleted.
|
||||
|
||||
Same vibe as [../convert](../convert).
|
@ -4,15 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
command "github.com/docker/cli/cli/command"
|
||||
"coopcloud.tech/abra/pkg/context"
|
||||
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
|
||||
dConfig "github.com/docker/cli/cli/config"
|
||||
context "github.com/docker/cli/cli/context"
|
||||
"github.com/docker/cli/cli/context/docker"
|
||||
contextStore "github.com/docker/cli/cli/context/store"
|
||||
dCliContextStore "github.com/docker/cli/cli/context/store"
|
||||
cliflags "github.com/docker/cli/cli/flags"
|
||||
dClient "github.com/docker/docker/client"
|
||||
"github.com/moby/term"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@ -30,13 +26,13 @@ func CreateContext(contextName string, user string, port string) error {
|
||||
if err := createContext(contextName, host); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("created the '%s' context", contextName)
|
||||
logrus.Debugf("created the %s context", contextName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createContext interacts with Docker Context to create a Docker context config
|
||||
func createContext(name string, host string) error {
|
||||
s := NewDefaultDockerContextStore()
|
||||
s := context.NewDefaultDockerContextStore()
|
||||
contextMetadata := contextStore.Metadata{
|
||||
Endpoints: make(map[string]interface{}),
|
||||
Name: name,
|
||||
@ -46,7 +42,7 @@ func createContext(name string, host string) error {
|
||||
Endpoints: make(map[string]contextStore.EndpointTLSData),
|
||||
}
|
||||
|
||||
dockerEP, dockerTLS, err := getDockerEndpointMetadataAndTLS(host)
|
||||
dockerEP, dockerTLS, err := commandconnPkg.GetDockerEndpointMetadataAndTLS(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -76,81 +72,20 @@ func DeleteContext(name string) error {
|
||||
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.CurrentContext = ""
|
||||
if err := cfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return NewDefaultDockerContextStore().Remove(name)
|
||||
return context.NewDefaultDockerContextStore().Remove(name)
|
||||
}
|
||||
|
||||
func GetContext(contextName string) (contextStore.Metadata, error) {
|
||||
ctx, err := NewDefaultDockerContextStore().GetMetadata(contextName)
|
||||
ctx, err := context.NewDefaultDockerContextStore().GetMetadata(contextName)
|
||||
if err != nil {
|
||||
return contextStore.Metadata{}, err
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func GetContextEndpoint(ctx contextStore.Metadata) (string, error) {
|
||||
endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase)
|
||||
if !ok {
|
||||
err := errors.New("context lacks Docker endpoint")
|
||||
return "", err
|
||||
}
|
||||
return endpointmeta.Host, nil
|
||||
}
|
||||
|
||||
func newContextStore(dir string, config contextStore.Config) contextStore.Store {
|
||||
return contextStore.New(dir, config)
|
||||
}
|
||||
|
||||
func NewDefaultDockerContextStore() *command.ContextStoreWithDefault {
|
||||
_, _, stderr := term.StdStreams()
|
||||
dockerConfig := dConfig.LoadDefaultConfigFile(stderr)
|
||||
contextDir := dConfig.ContextStoreDir()
|
||||
storeConfig := command.DefaultContextStoreConfig()
|
||||
store := newContextStore(contextDir, storeConfig)
|
||||
|
||||
opts := &cliflags.CommonOptions{Context: "default"}
|
||||
|
||||
dockerContextStore := &command.ContextStoreWithDefault{
|
||||
Store: store,
|
||||
Resolver: func() (*command.DefaultContext, error) {
|
||||
return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr)
|
||||
},
|
||||
}
|
||||
|
||||
return dockerContextStore
|
||||
}
|
||||
|
||||
func getDockerEndpointMetadataAndTLS(host string) (docker.EndpointMeta, *dCliContextStore.EndpointTLSData, error) {
|
||||
ep, err := getDockerEndpoint(host)
|
||||
if err != nil {
|
||||
return docker.EndpointMeta{}, nil, err
|
||||
}
|
||||
return ep.EndpointMeta, ep.TLSData.ToStoreTLSData(), nil
|
||||
}
|
||||
|
||||
func getDockerEndpoint(host string) (docker.Endpoint, error) {
|
||||
skipTLSVerify := false
|
||||
ep := docker.Endpoint{
|
||||
EndpointMeta: docker.EndpointMeta{
|
||||
Host: host,
|
||||
SkipTLSVerify: skipTLSVerify,
|
||||
},
|
||||
}
|
||||
// try to resolve a docker client, validating the configuration
|
||||
opts, err := ep.ClientOpts()
|
||||
if err != nil {
|
||||
return docker.Endpoint{}, err
|
||||
}
|
||||
if _, err := dClient.NewClientWithOpts(opts...); err != nil {
|
||||
return docker.Endpoint{}, err
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
dContext "github.com/docker/cli/cli/context"
|
||||
dCliContextStore "github.com/docker/cli/cli/context/store"
|
||||
)
|
||||
|
||||
type TestContext struct {
|
||||
context dCliContextStore.Metadata
|
||||
expected_endpoint string
|
||||
}
|
||||
|
||||
func dockerContext(host, key string) TestContext {
|
||||
dockerContext := dCliContextStore.Metadata{
|
||||
Name: "foo",
|
||||
Metadata: nil,
|
||||
Endpoints: map[string]interface{}{
|
||||
key: dContext.EndpointMetaBase{
|
||||
Host: host,
|
||||
SkipTLSVerify: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
return TestContext{
|
||||
context: dockerContext,
|
||||
expected_endpoint: host,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateContext(t *testing.T) {
|
||||
err := client.CreateContext("testContext0", "wronguser", "wrongport")
|
||||
if err == nil {
|
||||
t.Error("client.CreateContext(\"testContextCreate\", \"wronguser\", \"wrongport\") should have failed but didn't return an error")
|
||||
}
|
||||
err = client.CreateContext("testContext0", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("Couldn't create context: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteContext(t *testing.T) {
|
||||
ensureTestState(1, 1)
|
||||
err := client.DeleteContext("default")
|
||||
if err == nil {
|
||||
t.Errorf("client.DeleteContext(\"default\") should have failed but didn't return an error")
|
||||
}
|
||||
|
||||
err = client.DeleteContext("testContext0")
|
||||
if err != nil {
|
||||
t.Errorf("client.DeleteContext(\"testContext0\") failed: %s", err)
|
||||
}
|
||||
err = client.DeleteContext("testContextFail0")
|
||||
if err == nil {
|
||||
t.Errorf("client.DeleteContext(\"testContextFail0\") should have failed (attempt to delete non-existent context) but didn't return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContextEndpoint(t *testing.T) {
|
||||
var testDockerContexts = []TestContext{
|
||||
dockerContext("ssh://foobar", "docker"),
|
||||
dockerContext("ssh://foobar", "k8"),
|
||||
}
|
||||
for _, context := range testDockerContexts {
|
||||
endpoint, err := client.GetContextEndpoint(context.context)
|
||||
if err != nil {
|
||||
if err.Error() != "context lacks Docker endpoint" {
|
||||
t.Error(err)
|
||||
}
|
||||
} else {
|
||||
if endpoint != context.expected_endpoint {
|
||||
t.Errorf("did not get correct context endpoint. Expected: %s, received: %s", context.expected_endpoint, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,191 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2013-2017 Docker, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
@ -1,10 +0,0 @@
|
||||
# github.com/docker/cli/cli/compose/convert
|
||||
|
||||
DISCLAIMER: This is like the entire `github.com/docker/cli/cli/compose/convert`
|
||||
package. This should be an easy import but importing it creates DEPENDENCY
|
||||
HELL. I tried for an hour to fix it but it would work. TRY TO FIX AT YOUR OWN
|
||||
RISK!!!
|
||||
|
||||
Due to this literally just being copy-pasted from the lib, the Apache license
|
||||
will be posted in this folder. Small edits to the source code have been to
|
||||
function names and parts we don't need deleted.
|
@ -1,170 +1,57 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/web"
|
||||
"github.com/containers/image/docker"
|
||||
"github.com/containers/image/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type RawTag struct {
|
||||
Layer string
|
||||
Name string
|
||||
// GetRegistryTags retrieves all tags of an image from a container registry.
|
||||
func GetRegistryTags(img reference.Named) ([]string, error) {
|
||||
var tags []string
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
type RawTags []RawTag
|
||||
|
||||
var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags"
|
||||
|
||||
func GetRegistryTags(image string) (RawTags, error) {
|
||||
var tags RawTags
|
||||
|
||||
tagsUrl := fmt.Sprintf(registryURL, image)
|
||||
if err := web.ReadJSON(tagsUrl, &tags); err != nil {
|
||||
ctx := context.Background()
|
||||
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
|
||||
if err != nil {
|
||||
return tags, err
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// GetTagDigest retrieves an image digest from a container registry.
|
||||
func GetTagDigest(cl *client.Client, image reference.Named) (string, error) {
|
||||
target := fmt.Sprintf("//%s", reference.Path(image))
|
||||
|
||||
ref, err := docker.ParseReference(target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to parse image %s, saw: %s", image, err.Error())
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: web.Timeout}
|
||||
res, err := client.Do(req)
|
||||
ctx := context.Background()
|
||||
img, err := ref.NewImage(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
logrus.Debugf("failed to query remote registry for %s, saw: %s", image, err.Error())
|
||||
return "", fmt.Errorf("unable to read digest for %s", image)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
defer img.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]
|
||||
}
|
||||
}
|
||||
digest := img.ConfigInfo().Digest.String()
|
||||
|
||||
if digest == "" {
|
||||
if err := json.Unmarshal(body, ®istryResT2); err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest = strings.Split(registryResT2.Config.Digest, ":")[1][:7]
|
||||
return digest, fmt.Errorf("unable to read digest for %s", image)
|
||||
}
|
||||
|
||||
if digest == "" {
|
||||
return "", fmt.Errorf("Unable to retrieve amd64 digest for '%s'", image)
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
return strings.Split(digest, ":")[1][:7], nil
|
||||
}
|
||||
|
@ -5,23 +5,18 @@ import (
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Volume, error) {
|
||||
|
||||
func GetVolumes(ctx context.Context, server string, fs filters.Args) ([]*types.Volume, error) {
|
||||
cl, err := New(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs := filters.NewArgs()
|
||||
fs.Add("name", appName)
|
||||
|
||||
volumeListOKBody, err := cl.VolumeList(ctx, fs)
|
||||
volumeList := volumeListOKBody.Volumes
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return volumeList, err
|
||||
}
|
||||
|
||||
return volumeList, nil
|
||||
@ -29,9 +24,11 @@ func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Vo
|
||||
|
||||
func GetVolumeNames(volumes []*types.Volume) []string {
|
||||
var volumeNames []string
|
||||
|
||||
for _, vol := range volumes {
|
||||
volumeNames = append(volumeNames, vol.Name)
|
||||
}
|
||||
|
||||
return volumeNames
|
||||
}
|
||||
|
||||
@ -40,12 +37,13 @@ func RemoveVolumes(ctx context.Context, server string, volumeNames []string, for
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, volName := range volumeNames {
|
||||
err := cl.VolumeRemove(ctx, volName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -7,35 +7,36 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/client/stack"
|
||||
loader "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
loader "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UpdateTag updates an image tag in-place on file system local compose files.
|
||||
func UpdateTag(pattern, image, tag, recipeName string) error {
|
||||
func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
|
||||
composeFiles, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
logrus.Debugf("considering '%s' config(s) for tag update", strings.Join(composeFiles, ", "))
|
||||
logrus.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", "))
|
||||
|
||||
for _, composeFile := range composeFiles {
|
||||
opts := stack.Deploy{Composefiles: []string{composeFile}}
|
||||
|
||||
envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
|
||||
sampleEnv, err := config.ReadEnv(envSamplePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
compose, err := loader.LoadComposefile(opts, sampleEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, service := range compose.Services {
|
||||
@ -45,40 +46,42 @@ func UpdateTag(pattern, image, tag, recipeName string) error {
|
||||
|
||||
img, _ := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
composeImage := reference.Path(img)
|
||||
if strings.Contains(composeImage, "library") {
|
||||
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
|
||||
// postgres:<tag>, i.e. images which do not have a username in the
|
||||
// first position of the string
|
||||
composeImage = strings.Split(composeImage, "/")[1]
|
||||
var composeTag string
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
composeTag = img.(reference.NamedTagged).Tag()
|
||||
default:
|
||||
logrus.Debugf("unable to parse %s, skipping", img)
|
||||
continue
|
||||
}
|
||||
composeTag := img.(reference.NamedTagged).Tag()
|
||||
|
||||
logrus.Debugf("parsed '%s' from '%s'", composeTag, service.Image)
|
||||
composeImage := formatter.StripTagMeta(reference.Path(img))
|
||||
|
||||
logrus.Debugf("parsed %s from %s", composeTag, service.Image)
|
||||
|
||||
if image == composeImage {
|
||||
bytes, err := ioutil.ReadFile(composeFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
|
||||
new := fmt.Sprintf("%s:%s", composeImage, tag)
|
||||
replacedBytes := strings.Replace(string(bytes), old, new, -1)
|
||||
|
||||
logrus.Debugf("updating '%s' to '%s' in '%s'", old, new, compose.Filename)
|
||||
logrus.Debugf("updating %s to %s in %s", old, new, compose.Filename)
|
||||
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
|
||||
return err
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates a label in-place on file system local compose files.
|
||||
@ -88,12 +91,12 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("considering '%s' config(s) for label update", strings.Join(composeFiles, ", "))
|
||||
logrus.Debugf("considering %s config(s) for label update", strings.Join(composeFiles, ", "))
|
||||
|
||||
for _, composeFile := range composeFiles {
|
||||
opts := stack.Deploy{Composefiles: []string{composeFile}}
|
||||
|
||||
envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
|
||||
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
|
||||
sampleEnv, err := config.ReadEnv(envSamplePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -117,8 +120,11 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
discovered := false
|
||||
for oldLabel, value := range service.Deploy.Labels {
|
||||
if strings.HasPrefix(oldLabel, "coop-cloud") {
|
||||
discovered = true
|
||||
|
||||
bytes, err := ioutil.ReadFile(composeFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -127,13 +133,25 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
|
||||
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
|
||||
replacedBytes := strings.Replace(string(bytes), old, label, -1)
|
||||
|
||||
logrus.Debugf("updating '%s' to '%s' in '%s'", old, label, compose.Filename)
|
||||
if old == label {
|
||||
logrus.Warnf("%s is already set, nothing to do?", label)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
|
||||
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
|
||||
|
||||
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Infof("synced label %s to service %s", label, serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
if !discovered {
|
||||
logrus.Warn("no existing label found, automagic insertion not supported yet")
|
||||
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -1,18 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/cli/formatter"
|
||||
"coopcloud.tech/abra/pkg/client/convert"
|
||||
loader "coopcloud.tech/abra/pkg/client/stack"
|
||||
stack "coopcloud.tech/abra/pkg/client/stack"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/upstream/convert"
|
||||
loader "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@ -36,24 +37,75 @@ type AppFiles map[AppName]AppFile
|
||||
// App reprents an app with its env file read into memory
|
||||
type App struct {
|
||||
Name AppName
|
||||
Type string
|
||||
Recipe string
|
||||
Domain string
|
||||
Env AppEnv
|
||||
Server string
|
||||
Path string
|
||||
}
|
||||
|
||||
// StackName gets what the docker safe stack name is for the app
|
||||
// StackName gets what the docker safe stack name is for the app. This should
|
||||
// not not shown to the user, use a.Name for that. Give the output of this
|
||||
// command to Docker only.
|
||||
func (a App) StackName() string {
|
||||
if _, exists := a.Env["STACK_NAME"]; exists {
|
||||
return a.Env["STACK_NAME"]
|
||||
}
|
||||
|
||||
stackName := SanitiseAppName(a.Name)
|
||||
|
||||
if len(stackName) > 45 {
|
||||
logrus.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:45])
|
||||
stackName = stackName[:45]
|
||||
}
|
||||
|
||||
a.Env["STACK_NAME"] = stackName
|
||||
|
||||
return stackName
|
||||
}
|
||||
|
||||
// SORTING TYPES
|
||||
// Filters retrieves exact app filters for querying the container runtime. Due
|
||||
// to upstream issues, filtering works different depending on what you're
|
||||
// querying. So, for example, secrets don't work with regex! The caller needs
|
||||
// to implement their own validation that the right secrets are matched. In
|
||||
// order to handle these cases, we provide the `appendServiceNames` /
|
||||
// `exactMatch` modifiers.
|
||||
func (a App) Filters(appendServiceNames, exactMatch bool) (filters.Args, error) {
|
||||
filters := filters.NewArgs()
|
||||
|
||||
composeFiles, err := GetAppComposeFiles(a.Recipe, a.Env)
|
||||
if err != nil {
|
||||
return filters, err
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles}
|
||||
compose, err := GetAppComposeConfig(a.Recipe, opts, a.Env)
|
||||
if err != nil {
|
||||
return filters, err
|
||||
}
|
||||
|
||||
for _, service := range compose.Services {
|
||||
var filter string
|
||||
|
||||
if appendServiceNames {
|
||||
if exactMatch {
|
||||
filter = fmt.Sprintf("^%s_%s", a.StackName(), service.Name)
|
||||
} else {
|
||||
filter = fmt.Sprintf("%s_%s", a.StackName(), service.Name)
|
||||
}
|
||||
} else {
|
||||
if exactMatch {
|
||||
filter = fmt.Sprintf("^%s", a.StackName())
|
||||
} else {
|
||||
filter = fmt.Sprintf("%s", a.StackName())
|
||||
}
|
||||
}
|
||||
|
||||
filters.Add("name", filter)
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// ByServer sort a slice of Apps
|
||||
type ByServer []App
|
||||
@ -64,25 +116,25 @@ func (a ByServer) Less(i, j int) bool {
|
||||
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
|
||||
}
|
||||
|
||||
// ByServerAndType sort a slice of Apps
|
||||
type ByServerAndType []App
|
||||
// ByServerAndRecipe sort a slice of Apps
|
||||
type ByServerAndRecipe []App
|
||||
|
||||
func (a ByServerAndType) Len() int { return len(a) }
|
||||
func (a ByServerAndType) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByServerAndType) Less(i, j int) bool {
|
||||
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].Type) < strings.ToLower(a[j].Type)
|
||||
return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe)
|
||||
}
|
||||
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
|
||||
}
|
||||
|
||||
// ByType sort a slice of Apps
|
||||
type ByType []App
|
||||
// ByRecipe sort a slice of Apps
|
||||
type ByRecipe []App
|
||||
|
||||
func (a ByType) Len() int { return len(a) }
|
||||
func (a ByType) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByType) Less(i, j int) bool {
|
||||
return strings.ToLower(a[i].Type) < strings.ToLower(a[j].Type)
|
||||
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) < strings.ToLower(a[j].Recipe)
|
||||
}
|
||||
|
||||
// ByName sort a slice of Apps
|
||||
@ -97,14 +149,14 @@ func (a ByName) Less(i, j int) bool {
|
||||
func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
|
||||
env, err := ReadEnv(appFile.Path)
|
||||
if err != nil {
|
||||
return App{}, fmt.Errorf("env file for '%s' couldn't be read: %s", name, err.Error())
|
||||
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error())
|
||||
}
|
||||
|
||||
logrus.Debugf("read env '%s' from '%s'", env, appFile.Path)
|
||||
logrus.Debugf("read env %s from %s", env, appFile.Path)
|
||||
|
||||
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{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error())
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@ -112,17 +164,20 @@ func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
|
||||
|
||||
// newApp creates new App object
|
||||
func newApp(env AppEnv, name string, appFile AppFile) (App, error) {
|
||||
// Checking for type as it is required - apps wont work without it
|
||||
domain := env["DOMAIN"]
|
||||
apptype, ok := env["TYPE"]
|
||||
if !ok {
|
||||
return App{}, errors.New("missing TYPE variable")
|
||||
|
||||
recipe, exists := env["RECIPE"]
|
||||
if !exists {
|
||||
recipe, exists = env["TYPE"]
|
||||
if !exists {
|
||||
return App{}, fmt.Errorf("%s is missing the RECIPE env var", name)
|
||||
}
|
||||
}
|
||||
|
||||
return App{
|
||||
Name: name,
|
||||
Domain: domain,
|
||||
Type: apptype,
|
||||
Recipe: recipe,
|
||||
Env: env,
|
||||
Server: appFile.Server,
|
||||
Path: appFile.Path,
|
||||
@ -136,24 +191,24 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
|
||||
if servers[0] == "" {
|
||||
// Empty servers flag, one string will always be passed
|
||||
var err error
|
||||
servers, err = getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
|
||||
servers, err = GetAllFoldersInDirectory(SERVERS_DIR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("collecting metadata from '%v' servers: '%s'", len(servers), strings.Join(servers, ", "))
|
||||
logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", "))
|
||||
|
||||
for _, server := range servers {
|
||||
serverDir := path.Join(ABRA_SERVER_FOLDER, server)
|
||||
serverDir := path.Join(SERVERS_DIR, server)
|
||||
files, err := getAllFilesInDirectory(serverDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 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(ABRA_SERVER_FOLDER, server, file.Name())
|
||||
appFilePath := path.Join(SERVERS_DIR, server, file.Name())
|
||||
appFiles[appName] = AppFile{
|
||||
Path: appFilePath,
|
||||
Server: server,
|
||||
@ -169,7 +224,7 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
|
||||
func GetApp(apps AppFiles, name AppName) (App, error) {
|
||||
appFile, exists := apps[name]
|
||||
if !exists {
|
||||
return App{}, fmt.Errorf("cannot find app with name '%s'", name)
|
||||
return App{}, fmt.Errorf("cannot find app with name %s", name)
|
||||
}
|
||||
|
||||
app, err := readAppEnvFile(appFile, name)
|
||||
@ -209,13 +264,13 @@ func GetAppServiceNames(appName string) ([]string, error) {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
composeFiles, err := GetAppComposeFiles(app.Type, app.Env)
|
||||
composeFiles, err := GetAppComposeFiles(app.Recipe, app.Env)
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles}
|
||||
compose, err := GetAppComposeConfig(app.Type, opts, app.Env)
|
||||
compose, err := GetAppComposeConfig(app.Recipe, opts, app.Env)
|
||||
if err != nil {
|
||||
return serviceNames, err
|
||||
}
|
||||
@ -249,27 +304,45 @@ func GetAppNames() ([]string, error) {
|
||||
}
|
||||
|
||||
// TemplateAppEnvSample copies the example env file for the app into the users env files
|
||||
func TemplateAppEnvSample(appType, appName, server, domain, recipe string) error {
|
||||
envSamplePath := path.Join(ABRA_DIR, "apps", appType, ".env.sample")
|
||||
func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
|
||||
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample")
|
||||
envSample, err := ioutil.ReadFile(envSamplePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appEnvPath := path.Join(ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
|
||||
if _, err := os.Stat(appEnvPath); err == nil {
|
||||
if _, err := os.Stat(appEnvPath); os.IsExist(err) {
|
||||
return fmt.Errorf("%s already exists?", appEnvPath)
|
||||
}
|
||||
|
||||
envSample = []byte(strings.Replace(string(envSample), fmt.Sprintf("%s.example.com", recipe), domain, -1))
|
||||
envSample = []byte(strings.Replace(string(envSample), "example.com", domain, -1))
|
||||
|
||||
err = ioutil.WriteFile(appEnvPath, envSample, 0755)
|
||||
err = ioutil.WriteFile(appEnvPath, envSample, 0664)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("copied '%s' to '%s'", envSamplePath, appEnvPath)
|
||||
file, err := os.OpenFile(appEnvPath, os.O_RDWR, 0664)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tpl, err := template.ParseFiles(appEnvPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type templateVars struct {
|
||||
Name string
|
||||
Domain string
|
||||
}
|
||||
|
||||
tvars := templateVars{Name: recipeName, Domain: domain}
|
||||
if err := tpl.Execute(file, tvars); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -315,9 +388,6 @@ func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) {
|
||||
if version, ok := service.Spec.Labels[labelKey]; ok {
|
||||
result["version"] = version
|
||||
} else {
|
||||
//FIXME: we only need to check containers with the version label not
|
||||
// every single container and then skip when we see no label perf gains
|
||||
// to be had here
|
||||
continue
|
||||
}
|
||||
|
||||
@ -325,7 +395,7 @@ func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved app statuses: '%s'", statuses)
|
||||
logrus.Debugf("retrieved app statuses: %s", statuses)
|
||||
|
||||
return statuses, nil
|
||||
}
|
||||
@ -337,20 +407,20 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
|
||||
|
||||
if _, ok := appEnv["COMPOSE_FILE"]; !ok {
|
||||
logrus.Debug("no COMPOSE_FILE detected, loading compose.yml")
|
||||
path := fmt.Sprintf("%s/%s/compose.yml", APPS_DIR, recipe)
|
||||
path := fmt.Sprintf("%s/%s/compose.yml", RECIPES_DIR, recipe)
|
||||
composeFiles = append(composeFiles, path)
|
||||
return composeFiles, nil
|
||||
}
|
||||
|
||||
composeFileEnvVar := appEnv["COMPOSE_FILE"]
|
||||
envVars := strings.Split(composeFileEnvVar, ":")
|
||||
logrus.Debugf("COMPOSE_FILE detected ('%s'), loading '%s'", composeFileEnvVar, strings.Join(envVars, ", "))
|
||||
logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", "))
|
||||
for _, file := range strings.Split(composeFileEnvVar, ":") {
|
||||
path := fmt.Sprintf("%s/%s/%s", APPS_DIR, recipe, file)
|
||||
path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, file)
|
||||
composeFiles = append(composeFiles, path)
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved '%s' configs for '%s'", strings.Join(composeFiles, ", "), recipe)
|
||||
logrus.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), recipe)
|
||||
|
||||
return composeFiles, nil
|
||||
}
|
||||
@ -364,7 +434,7 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*comp
|
||||
return &composetypes.Config{}, err
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved '%s' for '%s'", compose.Filename, recipe)
|
||||
logrus.Debugf("retrieved %s for %s", compose.Filename, recipe)
|
||||
|
||||
return compose, nil
|
||||
}
|
||||
|
@ -26,7 +26,6 @@ func TestReadAppEnvFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetApp(t *testing.T) {
|
||||
// TODO: Test failures as well as successes
|
||||
app, err := GetApp(expectedAppFiles, appName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
@ -15,21 +15,24 @@ import (
|
||||
)
|
||||
|
||||
var ABRA_DIR = os.ExpandEnv("$HOME/.abra")
|
||||
var ABRA_SERVER_FOLDER = path.Join(ABRA_DIR, "servers")
|
||||
var APPS_JSON = path.Join(ABRA_DIR, "apps.json")
|
||||
var APPS_DIR = path.Join(ABRA_DIR, "apps")
|
||||
var SERVERS_DIR = path.Join(ABRA_DIR, "servers")
|
||||
var RECIPES_DIR = path.Join(ABRA_DIR, "recipes")
|
||||
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor")
|
||||
var RECIPES_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
|
||||
var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
|
||||
var CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
|
||||
var SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
|
||||
|
||||
// GetServers retrieves all servers.
|
||||
func GetServers() ([]string, error) {
|
||||
var servers []string
|
||||
|
||||
servers, err := getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
|
||||
servers, err := GetAllFoldersInDirectory(SERVERS_DIR)
|
||||
if err != nil {
|
||||
return servers, err
|
||||
}
|
||||
|
||||
logrus.Debugf("retrieved '%v' servers: '%s'", len(servers), servers)
|
||||
logrus.Debugf("retrieved %v servers: %s", len(servers), servers)
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@ -43,20 +46,20 @@ func ReadEnv(filePath string) (AppEnv, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.Debugf("read '%s' from '%s'", envFile, filePath)
|
||||
logrus.Debugf("read %s from %s", envFile, filePath)
|
||||
|
||||
return envFile, nil
|
||||
}
|
||||
|
||||
// ReadServerNames retrieves all server names.
|
||||
func ReadServerNames() ([]string, error) {
|
||||
serverNames, err := getAllFoldersInDirectory(ABRA_SERVER_FOLDER)
|
||||
serverNames, err := GetAllFoldersInDirectory(SERVERS_DIR)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.Debugf("read '%s' from '%s'", strings.Join(serverNames, ","), ABRA_SERVER_FOLDER)
|
||||
logrus.Debugf("read %s from %s", strings.Join(serverNames, ","), SERVERS_DIR)
|
||||
|
||||
return serverNames, nil
|
||||
}
|
||||
@ -80,7 +83,7 @@ func getAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
|
||||
|
||||
realPath, err := filepath.EvalSymlinks(filePath)
|
||||
if err != nil {
|
||||
logrus.Warningf("broken symlink in your abra config folders: '%s'", filePath)
|
||||
logrus.Warningf("broken symlink in your abra config folders: %s", filePath)
|
||||
} else {
|
||||
realFile, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
@ -95,8 +98,8 @@ func getAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
|
||||
return realFiles, nil
|
||||
}
|
||||
|
||||
// getAllFoldersInDirectory returns both folder and symlink paths
|
||||
func getAllFoldersInDirectory(directory string) ([]string, error) {
|
||||
// GetAllFoldersInDirectory returns both folder and symlink paths
|
||||
func GetAllFoldersInDirectory(directory string) ([]string, error) {
|
||||
var folders []string
|
||||
|
||||
files, err := ioutil.ReadDir(directory)
|
||||
@ -104,7 +107,7 @@ func getAllFoldersInDirectory(directory string) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("directory is empty: '%s'", directory)
|
||||
return nil, fmt.Errorf("directory is empty: %s", directory)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
@ -113,7 +116,7 @@ func getAllFoldersInDirectory(directory string) ([]string, error) {
|
||||
filePath := path.Join(directory, file.Name())
|
||||
realDir, err := filepath.EvalSymlinks(filePath)
|
||||
if err != nil {
|
||||
logrus.Warningf("broken symlink in your abra config folders: '%s'", filePath)
|
||||
logrus.Warningf("broken symlink in your abra config folders: %s", filePath)
|
||||
} else if stat, err := os.Stat(realDir); err == nil && stat.IsDir() {
|
||||
// path is a directory
|
||||
folders = append(folders, file.Name())
|
||||
@ -124,17 +127,6 @@ func getAllFoldersInDirectory(directory string) ([]string, error) {
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
// EnsureAbraDirExists checks for the abra config folder and throws error if not
|
||||
func EnsureAbraDirExists() error {
|
||||
if _, err := os.Stat(ABRA_DIR); os.IsNotExist(err) {
|
||||
logrus.Debugf("'%s' does not exist, creating it", ABRA_DIR)
|
||||
if err := os.Mkdir(ABRA_DIR, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadAbraShEnvVars reads env vars from an abra.sh recipe file.
|
||||
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
|
||||
envVars := make(map[string]string)
|
||||
@ -161,7 +153,7 @@ func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("read '%s' from '%s'", envVars, abraSh)
|
||||
logrus.Debugf("read %s from %s", envVars, abraSh)
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
|
@ -20,12 +20,12 @@ var serverName = "evil.corp"
|
||||
|
||||
var expectedAppEnv = AppEnv{
|
||||
"DOMAIN": "ecloud.evil.corp",
|
||||
"TYPE": "ecloud",
|
||||
"RECIPE": "ecloud",
|
||||
}
|
||||
|
||||
var expectedApp = App{
|
||||
Name: appName,
|
||||
Type: expectedAppEnv["TYPE"],
|
||||
Recipe: expectedAppEnv["RECIPE"],
|
||||
Domain: expectedAppEnv["DOMAIN"],
|
||||
Env: expectedAppEnv,
|
||||
Path: expectedAppFile.Path,
|
||||
@ -44,7 +44,7 @@ var expectedAppFiles = map[string]AppFile{
|
||||
// var expectedServerNames = []string{"evil.corp"}
|
||||
|
||||
func TestGetAllFoldersInDirectory(t *testing.T) {
|
||||
folders, err := getAllFoldersInDirectory(testFolder)
|
||||
folders, err := GetAllFoldersInDirectory(testFolder)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -74,11 +74,11 @@ func TestReadEnv(t *testing.T) {
|
||||
}
|
||||
if !reflect.DeepEqual(env, expectedAppEnv) {
|
||||
t.Fatalf(
|
||||
"did not get expected application settings. Expected: DOMAIN=%s TYPE=%s; Got: DOMAIN=%s TYPE=%s",
|
||||
"did not get expected application settings. Expected: DOMAIN=%s RECIPE=%s; Got: DOMAIN=%s RECIPE=%s",
|
||||
expectedAppEnv["DOMAIN"],
|
||||
expectedAppEnv["TYPE"],
|
||||
expectedAppEnv["RECIPE"],
|
||||
env["DOMAIN"],
|
||||
env["TYPE"],
|
||||
env["RECIPE"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
70
pkg/container/container.go
Normal file
70
pkg/container/container.go
Normal file
@ -0,0 +1,70 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetContainer retrieves a container. If noInput is false and the retrievd
|
||||
// count of containers does not match 1, then a prompt is presented to let the
|
||||
// user choose. A count of 0 is handled gracefully.
|
||||
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, noInput bool) (types.Container, error) {
|
||||
containerOpts := types.ContainerListOptions{Filters: filters}
|
||||
containers, err := cl.ContainerList(c, containerOpts)
|
||||
if err != nil {
|
||||
return types.Container{}, err
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
filter := filters.Get("name")[0]
|
||||
return types.Container{}, fmt.Errorf("no containers matching the %v filter found?", filter)
|
||||
}
|
||||
|
||||
if len(containers) != 1 {
|
||||
var containersRaw []string
|
||||
for _, container := range containers {
|
||||
containerName := strings.Join(container.Names, " ")
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
created := formatter.HumanDuration(container.Created)
|
||||
containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created))
|
||||
}
|
||||
|
||||
if noInput {
|
||||
err := fmt.Errorf("expected 1 container but found %v: %s", len(containers), strings.Join(containersRaw, " "))
|
||||
return types.Container{}, err
|
||||
}
|
||||
|
||||
logrus.Warnf("ambiguous container list received, prompting for input")
|
||||
|
||||
var response string
|
||||
prompt := &survey.Select{
|
||||
Message: "which container are you looking for?",
|
||||
Options: containersRaw,
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return types.Container{}, err
|
||||
}
|
||||
|
||||
chosenContainer := strings.TrimSpace(strings.Split(response, " ")[0])
|
||||
for _, container := range containers {
|
||||
containerName := strings.TrimSpace(strings.Join(container.Names, " "))
|
||||
trimmed := strings.TrimPrefix(containerName, "/")
|
||||
if trimmed == chosenContainer {
|
||||
return container, nil
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Panic("failed to match chosen container")
|
||||
}
|
||||
|
||||
return containers[0], nil
|
||||
}
|
44
pkg/context/context.go
Normal file
44
pkg/context/context.go
Normal file
@ -0,0 +1,44 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/docker/cli/cli/command"
|
||||
dConfig "github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/context"
|
||||
contextStore "github.com/docker/cli/cli/context/store"
|
||||
cliflags "github.com/docker/cli/cli/flags"
|
||||
"github.com/moby/term"
|
||||
)
|
||||
|
||||
func NewDefaultDockerContextStore() *command.ContextStoreWithDefault {
|
||||
_, _, stderr := term.StdStreams()
|
||||
dockerConfig := dConfig.LoadDefaultConfigFile(stderr)
|
||||
contextDir := dConfig.ContextStoreDir()
|
||||
storeConfig := command.DefaultContextStoreConfig()
|
||||
store := newContextStore(contextDir, storeConfig)
|
||||
|
||||
opts := &cliflags.CommonOptions{Context: "default"}
|
||||
|
||||
dockerContextStore := &command.ContextStoreWithDefault{
|
||||
Store: store,
|
||||
Resolver: func() (*command.DefaultContext, error) {
|
||||
return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr)
|
||||
},
|
||||
}
|
||||
|
||||
return dockerContextStore
|
||||
}
|
||||
|
||||
func GetContextEndpoint(ctx contextStore.Metadata) (string, error) {
|
||||
endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase)
|
||||
if !ok {
|
||||
err := errors.New("context lacks Docker endpoint")
|
||||
return "", err
|
||||
}
|
||||
return endpointmeta.Host, nil
|
||||
}
|
||||
|
||||
func newContextStore(dir string, config contextStore.Config) contextStore.Store {
|
||||
return contextStore.New(dir, config)
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
@ -26,3 +29,75 @@ func NewToken(provider, providerTokenEnvVar string) (string, error) {
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// EnsureIPv4 ensures that an ipv4 address is set for a domain name
|
||||
func EnsureIPv4(domainName string) (string, error) {
|
||||
var ipv4 string
|
||||
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ips, err := resolver.LookupIPAddr(ctx, domainName)
|
||||
if err != nil {
|
||||
return ipv4, err
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return ipv4, fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
|
||||
}
|
||||
|
||||
ipv4 = ips[0].IP.To4().String()
|
||||
logrus.Debugf("%s points to %s (resolver: %s)", domainName, ipv4, freifunkDNS)
|
||||
|
||||
return ipv4, nil
|
||||
}
|
||||
|
||||
// EnsureDomainsResolveSameIPv4 ensures that domains resolve to the same ipv4 address
|
||||
func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
|
||||
var ipv4 string
|
||||
|
||||
domainIPv4, err := EnsureIPv4(domainName)
|
||||
if err != nil {
|
||||
return ipv4, err
|
||||
}
|
||||
|
||||
if domainIPv4 == "" {
|
||||
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", domainName)
|
||||
}
|
||||
|
||||
serverIPv4, err := EnsureIPv4(server)
|
||||
if err != nil {
|
||||
return ipv4, err
|
||||
}
|
||||
|
||||
if serverIPv4 == "" {
|
||||
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", server)
|
||||
}
|
||||
|
||||
if domainIPv4 != serverIPv4 {
|
||||
err := "app domain %s (%s) does not appear to resolve to app server %s (%s)?"
|
||||
return ipv4, fmt.Errorf(err, domainName, domainIPv4, server, serverIPv4)
|
||||
}
|
||||
|
||||
return ipv4, nil
|
||||
}
|
||||
|
||||
// GetTTL parses a ttl string into a duration
|
||||
func GetTTL(ttl string) (time.Duration, error) {
|
||||
val, err := time.ParseDuration(ttl)
|
||||
if err != nil {
|
||||
return val, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
@ -1,23 +1,22 @@
|
||||
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"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
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
|
||||
@ -35,6 +34,7 @@ func HumanDuration(timestamp int64) string {
|
||||
// CreateTable prepares a table layout for output.
|
||||
func CreateTable(columns []string) *tablewriter.Table {
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetHeader(columns)
|
||||
return table
|
||||
}
|
||||
@ -50,3 +50,22 @@ func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
|
||||
progressbar.OptionSetDescription(title),
|
||||
)
|
||||
}
|
||||
|
||||
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
|
||||
func StripTagMeta(image string) string {
|
||||
originalImage := image
|
||||
|
||||
if strings.Contains(image, "docker.io") {
|
||||
image = strings.Split(image, "/")[1]
|
||||
}
|
||||
|
||||
if strings.Contains(image, "library") {
|
||||
image = strings.Split(image, "/")[1]
|
||||
}
|
||||
|
||||
if originalImage != image {
|
||||
logrus.Debugf("stripped %s to %s for parsing", originalImage, image)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
35
pkg/git/branch.go
Normal file
35
pkg/git/branch.go
Normal file
@ -0,0 +1,35 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// GetCurrentBranch retrieves the current branch of a repository
|
||||
func GetCurrentBranch(repository *git.Repository) (string, error) {
|
||||
branchRefs, err := repository.Branches()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headRef, err := repository.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var currentBranchName string
|
||||
err = branchRefs.ForEach(func(branchRef *plumbing.Reference) error {
|
||||
if branchRef.Hash() == headRef.Hash() {
|
||||
currentBranchName = branchRef.Name().String()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return currentBranchName, nil
|
||||
}
|
@ -3,10 +3,10 @@ package git
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@ -14,80 +14,27 @@ import (
|
||||
// Clone runs a git clone which accounts for different default branches.
|
||||
func Clone(dir, url string) error {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
logrus.Debugf("'%s' does not exist, attempting to git clone from '%s'", dir, url)
|
||||
logrus.Debugf("%s does not exist, attempting to git clone from %s", dir, url)
|
||||
_, err := git.PlainClone(dir, false, &git.CloneOptions{URL: url, Tags: git.AllTags})
|
||||
if err != nil {
|
||||
logrus.Debugf("cloning '%s' default branch failed, attempting from main branch", url)
|
||||
logrus.Debugf("cloning %s default branch failed, attempting from main branch", url)
|
||||
_, err := git.PlainClone(dir, false, &git.CloneOptions{
|
||||
URL: url,
|
||||
Tags: git.AllTags,
|
||||
ReferenceName: plumbing.ReferenceName("refs/heads/main"),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "authentication required") {
|
||||
name := filepath.Base(dir)
|
||||
return fmt.Errorf("unable to clone %s, does %s exist?", name, url)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
logrus.Debugf("'%s' has been git cloned successfully", dir)
|
||||
logrus.Debugf("%s has been git cloned successfully", dir)
|
||||
} else {
|
||||
logrus.Debugf("'%s' already exists, doing nothing", dir)
|
||||
logrus.Debugf("%s already exists", dir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureUpToDate ensures that a git repo on disk has the latest changes (git-fetch).
|
||||
func EnsureUpToDate(dir string) error {
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
branch := "master"
|
||||
if _, err := repo.Branch("master"); err != nil {
|
||||
if _, err := repo.Branch("main"); err != nil {
|
||||
logrus.Debugf("failed to select branch in '%s'", dir)
|
||||
return err
|
||||
}
|
||||
branch = "main"
|
||||
}
|
||||
|
||||
logrus.Debugf("choosing '%s' as main git branch in '%s'", branch, dir)
|
||||
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refName := fmt.Sprintf("refs/heads/%s", branch)
|
||||
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'", refName, dir)
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("successfully checked out '%s' in '%s'", branch, dir)
|
||||
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fetchOpts := &git.FetchOptions{
|
||||
RemoteName: "origin",
|
||||
RefSpecs: []config.RefSpec{"refs/heads/*:refs/remotes/origin/*"},
|
||||
Force: true,
|
||||
}
|
||||
if err := remote.Fetch(fetchOpts); err != nil {
|
||||
if !strings.Contains(err.Error(), "already up-to-date") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("successfully fetched all changes in '%s'", dir)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
56
pkg/git/commit.go
Normal file
56
pkg/git/commit.go
Normal file
@ -0,0 +1,56 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Commit runs a git commit
|
||||
func Commit(repoPath, glob, commitMessage string, dryRun bool) error {
|
||||
if commitMessage == "" {
|
||||
return fmt.Errorf("no commit message specified?")
|
||||
}
|
||||
|
||||
commitRepo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commitWorktree, err := commitRepo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patterns, err := GetExcludesFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(patterns) > 0 {
|
||||
commitWorktree.Excludes = append(patterns, commitWorktree.Excludes...)
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
err = commitWorktree.AddGlob(glob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("staged %s for commit", glob)
|
||||
} else {
|
||||
logrus.Debugf("dry run: did not stage %s for commit", glob)
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
_, err = commitWorktree.Commit(commitMessage, &git.CommitOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debug("git changes commited")
|
||||
} else {
|
||||
logrus.Debug("dry run: no changes commited")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
14
pkg/git/common.go
Normal file
14
pkg/git/common.go
Normal file
@ -0,0 +1,14 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// EnsureGitRepo ensures a git repo .git folder exists
|
||||
func EnsureGitRepo(repoPath string) error {
|
||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no .git directory in %s?", repoPath)
|
||||
}
|
||||
return nil
|
||||
}
|
38
pkg/git/init.go
Normal file
38
pkg/git/init.go
Normal file
@ -0,0 +1,38 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/go-git/go-git/v5"
|
||||
gitPkg "github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Init inits a new repo and commits all the stuff if you want
|
||||
func Init(repoPath string, commit bool) error {
|
||||
if _, err := gitPkg.PlainInit(repoPath, false); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Debugf("initialised new git repo in %s", repoPath)
|
||||
|
||||
if commit {
|
||||
commitRepo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
commitWorktree, err := commitRepo.Worktree()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := commitWorktree.AddWithOptions(&git.AddOptions{All: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = commitWorktree.Commit("init", &git.CommitOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Debugf("init committed all files for new git repo in %s", repoPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
43
pkg/git/push.go
Normal file
43
pkg/git/push.go
Normal file
@ -0,0 +1,43 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Push pushes the latest changes & optionally tags to the default remote
|
||||
func Push(repoDir string, remote string, tags bool, dryRun bool) error {
|
||||
if dryRun {
|
||||
logrus.Debugf("dry run: no git changes pushed in %s", repoDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
commitRepo, err := git.PlainOpen(repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := &git.PushOptions{}
|
||||
if remote != "" {
|
||||
opts.RemoteName = remote
|
||||
}
|
||||
|
||||
if err := commitRepo.Push(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("git changes pushed")
|
||||
|
||||
if tags {
|
||||
opts.RefSpecs = append(opts.RefSpecs, config.RefSpec("+refs/tags/*:refs/tags/*"))
|
||||
|
||||
if err := commitRepo.Push(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("git tags pushed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
149
pkg/git/read.go
149
pkg/git/read.go
@ -1,16 +1,24 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/go-git/go-git/v5"
|
||||
gitConfigPkg "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetRecipeHead retrieves latest HEAD metadata.
|
||||
func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
|
||||
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
|
||||
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
|
||||
|
||||
repo, err := git.PlainOpen(recipeDir)
|
||||
if err != nil {
|
||||
@ -26,10 +34,8 @@ func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
|
||||
}
|
||||
|
||||
// IsClean checks if a repo has unstaged changes
|
||||
func IsClean(recipeName string) (bool, error) {
|
||||
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
|
||||
|
||||
repo, err := git.PlainOpen(recipeDir)
|
||||
func IsClean(repoPath string) (bool, error) {
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@ -39,10 +45,143 @@ func IsClean(recipeName string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
patterns, err := GetExcludesFiles()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(patterns) > 0 {
|
||||
worktree.Excludes = append(patterns, worktree.Excludes...)
|
||||
}
|
||||
|
||||
status, err := worktree.Status()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if status.String() != "" {
|
||||
logrus.Debugf("discovered git status in %s: %s", repoPath, status.String())
|
||||
} else {
|
||||
logrus.Debugf("discovered clean git status in %s", repoPath)
|
||||
}
|
||||
|
||||
return status.IsClean(), nil
|
||||
}
|
||||
|
||||
// GetExcludesFiles reads the exlude files from a global gitignore
|
||||
func GetExcludesFiles() ([]gitignore.Pattern, error) {
|
||||
var err error
|
||||
var patterns []gitignore.Pattern
|
||||
|
||||
cfg, err := parseGitConfig()
|
||||
if err != nil {
|
||||
return patterns, err
|
||||
}
|
||||
|
||||
excludesfile := getExcludesFile(cfg)
|
||||
patterns, err = parseExcludesFile(excludesfile)
|
||||
if err != nil {
|
||||
return patterns, err
|
||||
}
|
||||
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
func parseGitConfig() (*gitConfigPkg.Config, error) {
|
||||
cfg := gitConfigPkg.NewConfig()
|
||||
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
globalGitConfig := filepath.Join(usr.HomeDir, ".gitconfig")
|
||||
if _, err := os.Stat(globalGitConfig); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Debugf("no %s exists, not reading any global gitignore config", globalGitConfig)
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadFile(globalGitConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cfg.Unmarshal(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func getExcludesFile(cfg *gitConfigPkg.Config) string {
|
||||
for _, sec := range cfg.Raw.Sections {
|
||||
if sec.Name == "core" {
|
||||
for _, opt := range sec.Options {
|
||||
if opt.Key == "excludesfile" {
|
||||
return opt.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "~/.gitignore"
|
||||
}
|
||||
|
||||
func parseExcludesFile(excludesfile string) ([]gitignore.Pattern, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
excludesfile, err := expandTilde(excludesfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(excludesfile); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logrus.Debugf("no %s exists, skipping reading gitignore paths", excludesfile)
|
||||
return ps, nil
|
||||
}
|
||||
return ps, err
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(excludesfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pathsRaw []string
|
||||
for _, s := range strings.Split(string(data), "\n") {
|
||||
if !strings.HasPrefix(s, "#") && len(strings.TrimSpace(s)) > 0 {
|
||||
pathsRaw = append(pathsRaw, s)
|
||||
ps = append(ps, gitignore.ParsePattern(s, nil))
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("read global ignore paths: %s", strings.Join(pathsRaw, " "))
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func expandTilde(path string) (string, error) {
|
||||
if !strings.HasPrefix(path, "~") {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, p := range strings.Split(path, string(filepath.Separator)) {
|
||||
if p == "~" {
|
||||
paths = append(paths, u.HomeDir)
|
||||
} else {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(paths...), nil
|
||||
}
|
||||
|
28
pkg/git/remote.go
Normal file
28
pkg/git/remote.go
Normal file
@ -0,0 +1,28 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CreateRemote creates a new git remote in a repository
|
||||
func CreateRemote(repo *git.Repository, name, url string, dryRun bool) error {
|
||||
if dryRun {
|
||||
logrus.Debugf("dry run: remote %s (%s) not created", name, url)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := repo.CreateRemote(&config.RemoteConfig{
|
||||
Name: name,
|
||||
URLs: []string{url},
|
||||
}); err != nil {
|
||||
if !strings.Contains(err.Error(), "remote already exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
12
pkg/integration/integration.go
Normal file
12
pkg/integration/integration.go
Normal file
@ -0,0 +1,12 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func skipIfNotIntegration(t *testing.T) {
|
||||
if os.Getenv("ABRA_INTEGRATION") == "" {
|
||||
t.Skip("missing 'ABRA_INTEGRATION', not running integration tests")
|
||||
}
|
||||
}
|
20
pkg/limit/limit.go
Normal file
20
pkg/limit/limit.go
Normal file
@ -0,0 +1,20 @@
|
||||
package limit // https://github.com/tidwall/limiter
|
||||
|
||||
// Limiter is for limiting the number of concurrent operations. This
|
||||
type Limiter struct{ sem chan struct{} }
|
||||
|
||||
// New returns a new Limiter. The limit param is the maximum number of
|
||||
// concurrent operations.
|
||||
func New(limit int) *Limiter {
|
||||
return &Limiter{make(chan struct{}, limit)}
|
||||
}
|
||||
|
||||
// Begin an operation.
|
||||
func (l *Limiter) Begin() {
|
||||
l.sem <- struct{}{}
|
||||
}
|
||||
|
||||
// End the operation.
|
||||
func (l *Limiter) End() {
|
||||
<-l.sem
|
||||
}
|
338
pkg/lint/recipe.go
Normal file
338
pkg/lint/recipe.go
Normal file
@ -0,0 +1,338 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/recipe"
|
||||
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
||||
"coopcloud.tech/tagcmp"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var Warn = "warn"
|
||||
var Critical = "critical"
|
||||
|
||||
type LintFunction func(recipe.Recipe) (bool, error)
|
||||
|
||||
type LintRule struct {
|
||||
Ref string
|
||||
Level string
|
||||
Description string
|
||||
HowToResolve string
|
||||
Function LintFunction
|
||||
}
|
||||
|
||||
var LintRules = map[string][]LintRule{
|
||||
"warn": {
|
||||
{
|
||||
Ref: "R001",
|
||||
Level: "warn",
|
||||
Description: "compose config has expected version",
|
||||
HowToResolve: "ensure 'version: \"3.8\"' in compose configs",
|
||||
Function: LintComposeVersion,
|
||||
},
|
||||
{
|
||||
Ref: "R002",
|
||||
Level: "warn",
|
||||
Description: "healthcheck enabled for all services",
|
||||
HowToResolve: "wire up healthchecks",
|
||||
Function: LintHealthchecks,
|
||||
},
|
||||
{
|
||||
Ref: "R003",
|
||||
Level: "warn",
|
||||
Description: "all images use a tag",
|
||||
HowToResolve: "use a tag for all images",
|
||||
Function: LintAllImagesTagged,
|
||||
},
|
||||
{
|
||||
Ref: "R004",
|
||||
Level: "warn",
|
||||
Description: "no unstable tags",
|
||||
HowToResolve: "tag all images with stable tags",
|
||||
Function: LintNoUnstableTags,
|
||||
},
|
||||
{
|
||||
Ref: "R005",
|
||||
Level: "warn",
|
||||
Description: "tags use semver-like format",
|
||||
HowToResolve: "use semver-like tags",
|
||||
Function: LintSemverLikeTags,
|
||||
},
|
||||
{
|
||||
Ref: "R006",
|
||||
Level: "warn",
|
||||
Description: "has published catalogue version",
|
||||
HowToResolve: "publish a recipe version to the catalogue",
|
||||
Function: LintHasPublishedVersion,
|
||||
},
|
||||
{
|
||||
Ref: "R007",
|
||||
Level: "warn",
|
||||
Description: "README.md metadata filled in",
|
||||
HowToResolve: "fill out all the metadata",
|
||||
Function: LintMetadataFilledIn,
|
||||
},
|
||||
{
|
||||
Ref: "R013",
|
||||
Level: "warn",
|
||||
Description: "git.coopcloud.tech repo exists",
|
||||
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
|
||||
Function: LintHasRecipeRepo,
|
||||
},
|
||||
},
|
||||
"error": {
|
||||
{
|
||||
Ref: "R008",
|
||||
Level: "error",
|
||||
Description: ".env.sample provided",
|
||||
HowToResolve: "create an example .env.sample",
|
||||
Function: LintEnvConfigPresent,
|
||||
},
|
||||
{
|
||||
Ref: "R009",
|
||||
Level: "error",
|
||||
Description: "one service named 'app'",
|
||||
HowToResolve: "name a servce 'app'",
|
||||
Function: LintAppService,
|
||||
},
|
||||
{
|
||||
Ref: "R010",
|
||||
Level: "error",
|
||||
Description: "traefik routing enabled",
|
||||
HowToResolve: "include \"traefik.enable=true\" deploy label",
|
||||
Function: LintTraefikEnabled,
|
||||
},
|
||||
{
|
||||
Ref: "R011",
|
||||
Level: "error",
|
||||
Description: "all services have images",
|
||||
HowToResolve: "ensure \"image: ...\" set on all services",
|
||||
Function: LintImagePresent,
|
||||
},
|
||||
{
|
||||
Ref: "R012",
|
||||
Level: "error",
|
||||
Description: "config version are vendored",
|
||||
HowToResolve: "vendor config versions in an abra.sh",
|
||||
Function: LintAbraShVendors,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func LintForErrors(recipe recipe.Recipe) error {
|
||||
logrus.Debugf("linting for critical errors in %s configs", recipe.Name)
|
||||
|
||||
for level := range LintRules {
|
||||
if level != "error" {
|
||||
continue
|
||||
}
|
||||
for _, rule := range LintRules[level] {
|
||||
ok, err := rule.Function(recipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("lint error in %s configs: \"%s\" failed lint checks (%s)", recipe.Name, rule.Description, rule.Ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Debugf("linting successful, %s is well configured", recipe.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
|
||||
if recipe.Config.Version == "3.8" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) {
|
||||
envSample := fmt.Sprintf("%s/%s/.env.sample", config.RECIPES_DIR, recipe.Name)
|
||||
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func LintAppService(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
if service.Name == "app" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
for label := range service.Deploy.Labels {
|
||||
if label == "traefik.enable" {
|
||||
if service.Deploy.Labels[label] == "true" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
if service.HealthCheck == nil {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if reference.IsNameOnly(img) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var tag string
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
tag = img.(reference.NamedTagged).Tag()
|
||||
case reference.Named:
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if tag == "latest" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
img, err := reference.ParseNormalizedNamed(service.Image)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var tag string
|
||||
switch img.(type) {
|
||||
case reference.NamedTagged:
|
||||
tag = img.(reference.NamedTagged).Tag()
|
||||
case reference.Named:
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if !tagcmp.IsParsable(tag) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintImagePresent(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
if service.Image == "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
|
||||
catl, err := recipePkg.ReadRecipeCatalogue()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
|
||||
features, category, err := recipe.GetRecipeFeaturesAndCategory(r.Name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if category == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if features.Backups == "" ||
|
||||
features.Email == "" ||
|
||||
features.Healthcheck == "" ||
|
||||
features.Image.Image == "" ||
|
||||
features.SSO == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
|
||||
for _, service := range recipe.Config.Services {
|
||||
if len(service.Configs) > 0 {
|
||||
abraSh := path.Join(config.RECIPES_DIR, recipe.Name, "abra.sh")
|
||||
if _, err := os.Stat(abraSh); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
|
||||
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe.Name)
|
||||
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user