Compare commits

...

415 Commits

Author SHA1 Message Date
f432bfdd23
fix: warn when no repo on git
Some checks failed
continuous-integration/drone/push Build is failing
2022-03-18 10:13:24 +01:00
848e17578d chore(deps): update golang docker tag to v1.18 2022-03-16 08:01:41 +00:00
1615130929
fix: skip prompt for no passwords 2022-03-15 10:54:05 +01:00
7f315315f0
fix: better prompts & matching for secret removal 2022-03-13 10:59:19 +01:00
6a50981120
fix: match on generation of single secret 2022-03-13 10:50:35 +01:00
c67471e6ca
fix: show which secret was generated 2022-03-13 10:45:08 +01:00
f0fc1027e5
feat: more info on volumes. skip driver info 2022-03-12 17:11:05 +01:00
c66695d55e
fix: return err not logrus + new lines 2022-03-12 17:02:04 +01:00
262009701e
fix: guard against concurrent write errors 2022-03-12 16:59:45 +01:00
b31cb6b866
feat: prompt for secret generation
Closes coop-cloud/organising#302.
2022-03-12 16:47:19 +01:00
f39e186b66
fix: match Force/NoInput where needed 2022-03-12 16:15:20 +01:00
a8f35bdf2f
fix: handle NoInput for volume removal 2022-03-12 16:09:05 +01:00
6e1e02ac28
chore: use same flag docs style 2022-03-12 16:08:44 +01:00
16fc5ee54b
fix: can't force remove if it is already deployed 2022-03-12 16:08:26 +01:00
37a1fcc4af
fix: delete all secrets if force/noinput 2022-03-12 16:01:42 +01:00
a9b522719f
fix: use name not stack name for pass storage 2022-03-12 16:01:31 +01:00
ce70932a1c
feat: single char short flag for volumes removal 2022-03-12 16:01:14 +01:00
d61e104536
fix: look at removal flag for pass logic 2022-03-12 15:48:43 +01:00
d5f30a3ae4
fix: use removal flag with correct help 2022-03-12 15:48:26 +01:00
2555096510
feat: short flags for run command 2022-03-12 15:42:29 +01:00
3797292b20
fix: no domain/converge check for deploy/upgrade/rollback 2022-03-12 15:36:43 +01:00
6333815b71
fix: remove unused flag 2022-03-12 15:32:23 +01:00
793a850fd5
refactor!: short flags for server add 2022-03-12 15:30:43 +01:00
42c1450384
refactor!: prefer short flags on release 2022-03-12 15:28:33 +01:00
a2377882f6
refacator!: use single char short flags 2022-03-12 15:27:19 +01:00
e78b395662
feat: new short flag for RC upgrading 2022-03-12 15:24:19 +01:00
cdec834ca9
reformat: remove extra line in CLI help 2022-03-12 10:20:37 +01:00
b4b0b464bd
fix: only delete secrets from specific app
See coop-cloud/organising#300.
2022-03-12 09:39:30 +01:00
d8a1b0ccc1
doc: indicate storage location of secret in logs 2022-03-12 09:39:15 +01:00
3fbd381f55
fix: add pass remove flag & show name is optional 2022-03-12 09:17:24 +01:00
d3e127e5c8
fix: retain backwards compat with TYPE/RECIPE change 2022-03-11 19:37:50 +01:00
e9cfb076c6
fix: strip length modifiers
See coop-cloud/organising#297.
2022-03-11 16:40:10 +01:00
8ccf856110
fix: lay out generated secrets with warning/clarification 2022-03-11 16:39:34 +01:00
d0945aa09d
fix: handle NoInput for app removal 2022-03-11 16:39:20 +01:00
123619219e
chore: go mod tidy 2022-03-11 09:17:37 +01:00
a27410952e
Merge remote-tracking branch 'origin/renovate/main-github.com-docker-docker-20.x' into main 2022-03-11 09:17:15 +01:00
13e0392af6 chore(deps): update module github.com/docker/docker to v20.10.13 2022-03-11 08:01:57 +00:00
99a6135f72 chore(deps): update module github.com/docker/cli to v20.10.13 2022-03-11 08:01:45 +00:00
a6b52c1354
chore: go mod tidy [ci skip] 2022-03-09 12:28:26 +01:00
fa51459191 chore(deps): update module github.com/docker/distribution to v2.8.1 2022-03-09 08:01:26 +00:00
c529988427
feat: output success for secret insert [ci skip] 2022-03-08 18:10:37 +01:00
231cc3c718
fix: use StackName to filter volumes 2022-03-08 18:04:47 +01:00
3381b8936d
fix: better error handling & proper context deletion for server rm 2022-02-24 15:57:52 +01:00
823f869f1d
fix: error out correctly from ValidateDomain 2022-02-24 15:57:40 +01:00
ecbeacf10f
fix: prompt for container choice correctly on run [ci skip] 2022-02-22 11:47:36 +01:00
3f838038d5
chore: go mod tidy 2022-02-22 10:52:14 +01:00
91b4e021d0 chore(deps): update module github.com/containers/image to v5 2022-02-22 08:01:12 +00:00
598e87dca2 chore: skip new repositories 2022-02-21 08:46:30 +00:00
001511876d chore: go mod tidy 2022-02-21 08:46:30 +00:00
b295958c17 fix: handle all container registries
See coop-cloud/organising#258

This fixes also how we read the digest of the image. I think it was
wrong before. Some registries restrict reading this info and we now just
default to "unknown" for that case.

This also appears to bring a wave of new dependencies due to the generic
handling logic of containers/... package. The abra binary is now 1mb
larger.

The catalogue generation is now slower unfortunately. But it is more
robust.

The generic logic looks in ~/.docker/config.json for log in details, so
you don't have to pass those in manually on the CLI anymore. We just
read those defaults. You can "docker login" to get credentials setup in
that file. Since most folks won't generate the catalogue, this seems
fine for now.
2022-02-21 08:46:30 +00:00
2fbdcfb958
refactor: try the meta for default branch too
Sometimes the Branch(...) call gets confused with state in the
repository. Its more robust to use the default value we get from gitea.

See coop-cloud/organising#299.
2022-02-20 18:07:49 +01:00
09ac74d205
fix: check out default branch from tags
Also fix error handling to match function signatures.
2022-02-18 11:17:43 +01:00
5da4afa0ec
fix: only ensure latest after cloning 2022-02-18 09:55:07 +01:00
9d5e805748
chore: go mod tidy 2022-02-16 13:53:09 +01:00
770ae5ed9b chore(deps): update module github.com/moby/sys/signal to v0.7.0 2022-02-16 08:01:33 +00:00
e056d8dc44
fix: de-dupe dns resolver logging, more concise [ci skip] 2022-02-14 18:06:06 +01:00
c3442354e7
fix: skip dupe ipv4 check, done in EnsureDomainsResolveSameIPv4 2022-02-14 17:44:15 +01:00
6b2a0011af
fix: remove dupe logging on catalogue reading [ci skip] 2022-02-14 17:37:25 +01:00
46fca7cfa7
docs: less ambig wording [ci skip] 2022-02-14 17:35:42 +01:00
82d560a946
fix: prompt for input on app cp 2022-02-14 17:10:53 +01:00
fc5107865b
fix: typo 2022-02-10 10:59:19 +01:00
53ed1fc545
chore: go mod tidy 2022-02-09 09:59:23 +01:00
cc9e3d4e60
chore(deps): update module github.com/docker/distribution to v2.8.0 2022-02-09 09:59:23 +01:00
0557284461 fix: use new repo name 2022-02-09 08:58:51 +00:00
b5f23d3791 feat: show latest published version on sync 2022-02-09 08:58:20 +00:00
2b2dcc01b4
fix: dont checkout latest if we dont have a copy 2022-02-09 09:54:02 +01:00
0a208d049e
chore: go mod tidy + patch upgrades 2022-02-04 10:50:55 +01:00
141711ecd0
Merge remote-tracking branch 'origin/renovate/main-github.com-schollz-progressbar-v3-3.x' into main 2022-02-04 10:50:36 +01:00
cd46d71ce4 chore(deps): update module github.com/schollz/progressbar/v3 to v3.8.6 2022-02-04 08:01:17 +00:00
6fa090352d chore(deps): update module github.com/buger/goterm to v1.0.4 2022-02-04 08:01:11 +00:00
227c02cd09
refactor!: make common flags single char again 2022-02-03 14:19:51 +01:00
bfeda40e34
fix: catch more ssh failure modes with help 2022-02-03 13:43:11 +01:00
5237c7ed50
docs: focus more on straight ssh docs for server add 2022-02-03 13:42:49 +01:00
4e09f3b9a8
refactor: migrate authors to dedicated file [ci skip] 2022-02-02 21:00:00 +01:00
dfb32cbb68
fix: type -> recipe [ci skip] 2022-02-02 20:48:12 +01:00
bdd9b0a1aa
fix: ensure recipes on latest for lint/generate
Follows b2d17a1829c5415c4700cf5da85144af4de87162.
2022-01-29 14:06:25 +01:00
b2d17a1829
fix: ensure latest checked out for recipe upgrade 2022-01-29 13:35:42 +01:00
c905376472
refactor!: use "config" instead of "compose" [ci skip] 2022-01-27 12:24:33 +01:00
d316de218c
feat: include recipe in deploy & friends overview 2022-01-27 12:23:02 +01:00
123475bd36
chore: remove old files [ci skip] 2022-01-27 12:14:01 +01:00
58e98f490d
refactor!: type -> recipes 2022-01-27 12:06:32 +01:00
224b8865bf
test: newlines for output when Y'ing & N'ing 2022-01-27 12:05:22 +01:00
8fb9f42f13
test: add remaining scripts 2022-01-27 12:05:21 +01:00
dc5e2a5b24
test: fix pwd usage, PWD doesn't exist 2022-01-27 12:05:21 +01:00
40b4ef5ab2
test: disable debug, its too much noise 2022-01-27 12:05:21 +01:00
4a912ae3bc
test: show how to run all tests 2022-01-27 12:05:21 +01:00
1150fcc595
test: remove manual test guide, using semi-automated now 2022-01-27 12:05:20 +01:00
45224d1349
test: use new flags + order for record/server 2022-01-27 12:05:20 +01:00
7a40e2d616
fix: remove duplicate flags on "server new" 2022-01-27 12:05:20 +01:00
2277e4ef72
refactor!: remove no-input flag where not needed 2022-01-27 12:05:19 +01:00
c0c3d9fe76
refactor!: make dry-run flag more convenient 2022-01-27 12:05:19 +01:00
2493921ade
refactor!: de-duplicate record flags 2022-01-27 12:05:19 +01:00
22f9cf2be4
refactor: remove unused flag 2022-01-27 12:05:18 +01:00
a23124aede feat: auto strip domain names to avoid runtime limits 2022-01-27 10:33:21 +00:00
e670844b56 refactor!: app name -> domain 2022-01-27 10:33:21 +00:00
bc1729c5ca
trim docs, point to new docs [ci skip] 2022-01-27 10:30:28 +01:00
fa8611b115
fix: respect NoInput on "app cp" & use app to get StackName 2022-01-25 11:39:38 +01:00
415df981ff
test: long flags, drop docker, use run_tests for all tests 2022-01-24 16:49:51 +01:00
57728e58e8
test: improve semi-manual testing 2022-01-21 16:48:42 +01:00
c7062e0494
fix: initial subcmd completion
Broken by migration to v1 API.
2022-01-20 11:42:04 +01:00
cff7534bf9
chore: publish 0.4.0-alpha-rc6 2022-01-19 13:33:32 +01:00
13e582349c
fix: correctly override with ~/.ssh/config if failing to connect 2022-01-19 13:28:57 +01:00
b1b9612e01
fix: dont try to parse empty values on status lookup 2022-01-19 12:38:41 +01:00
afeee1270e
test: break up integration, rejig manual 2022-01-19 12:17:09 +01:00
cb210d0c81
docs: pass on flag/help strings 2022-01-19 11:21:06 +01:00
9f2bb3f74f
refactor!: remove auto dns, too magic, too broken 2022-01-19 11:20:51 +01:00
a33767f848
refactor!: drop auto traefik deploy, rarely works 2022-01-19 11:08:43 +01:00
a1abe5c6be
refactor!: drop backup/restore for now
This will be done with the bot from now on.
2022-01-19 11:06:54 +01:00
672b44f965
test: remove since we're not supporting that in abra now 2022-01-19 11:04:28 +01:00
6d9573ec7e
test: more help for how to do this 2022-01-19 11:04:15 +01:00
53cd3b8b71
fix: drop duplicate flags 2022-01-19 10:58:09 +01:00
b9ec41647b
fix: when upgrading, skip over bad tags, don't error out 2022-01-19 10:40:55 +01:00
f4b563528f
docs: point to new option for better assurance on tag listing 2022-01-19 10:40:37 +01:00
f9a2c1d58f
refactor: put StripTagMeta into formatter package
Avoid circular import.
2022-01-19 10:40:14 +01:00
7a66a90ecb
fix!: change dry-run alias to not conflict with debug 2022-01-18 17:13:28 +01:00
0e688f1407
refactor!: migrate to urfave/cli v1
Better flexible flags handling.
2022-01-18 14:38:20 +01:00
c6db9ee355
chore: publish 0.4.0-alpha-rc5 2022-01-18 11:39:02 +01:00
7733637767
fix: ensure catalogue cloned for catalogue reliant commands 2022-01-18 11:19:33 +01:00
88f9796aaf
fix: let us know if not pushing changes without dry-run (recipe release) 2022-01-18 10:55:07 +01:00
6cdba0f9de
fix: commit changes if dry-run not present (recipe release) 2022-01-18 10:54:54 +01:00
199aa5f4e3
fix: read password length from env files 2022-01-17 22:34:32 +01:00
9b26c24a5f
docs: drop that, not happening 2022-01-17 22:27:25 +01:00
ca75654769
fix: read correct app file name for secret generation
Stack name is only an internal docker concept now.
2022-01-17 22:17:59 +01:00
fc2d83d203
fix: better error message for missing server 2022-01-17 22:04:11 +01:00
2f4f288a46
feat: -a/--all-tags for listing all tags on recipe upgrade 2022-01-17 21:59:31 +01:00
e98f00d354
chore: go mod tidy 2022-01-17 21:50:25 +01:00
b4c2773b87 chore(deps): update module gotest.tools/v3 to v3.1.0 2022-01-17 08:01:18 +00:00
3aec5d1d7e
fix: ignore new test repo 2022-01-12 16:11:18 +01:00
e0fa1b6995
fix: let users know what was deleted 2022-01-06 11:47:10 +01:00
b69ab0df65
fix: chaos mode fixed for upgrade/rollback
Follows 4b7ec6384caed62217b672ab22c29dfa9fdced71.
2022-01-06 10:32:24 +01:00
69a7d37fb7
chore: release 0.4.0-alpha-rc4 2022-01-06 10:04:43 +01:00
87649cbbd0
docs: more manual test cases [ci skip] 2022-01-05 19:37:41 +01:00
4b7ec6384c
fix: fix chaos mode for deployment 2022-01-05 19:21:41 +01:00
b22b63c2ba
fix: only output if volumes selected for removal 2022-01-05 19:00:09 +01:00
d9f3a11265
fix: gracefully handle missing tag for syncing 2022-01-05 18:04:46 +01:00
d7cf11b876
fix: further fixes for gracefully handling missing tag
Follows 1b37d2d5f5e3a4b8c45481f082cb6f9d25cefdae.
2022-01-05 17:58:15 +01:00
d7e1b2947a
fix: skip failed image parse for upgrade and move on 2022-01-05 17:57:11 +01:00
1b37d2d5f5
fix: handle tags without images gracefully 2022-01-05 17:32:58 +01:00
74dfb12fd6
refactor: centralise tag meta stripping 2022-01-05 17:32:33 +01:00
49ccf2d204
fix: also show skip for non semver tags 2022-01-04 22:49:36 +01:00
76adc45431
docs: match typically log message style 2022-01-04 22:49:23 +01:00
e38a0078f3
chore: publish 0.4.0-alpha-rc3 2022-01-04 15:34:10 +01:00
25b44dc54e
refactor!: use lowercase option to match others 2022-01-04 12:25:45 +01:00
0c2f6fb676
fix: app autocomplete for secret commands 2022-01-04 12:24:37 +01:00
10e4a8b97f
fix: handle StackName/AppName correctly for new app creation 2022-01-04 11:56:29 +01:00
eed2756784
fix: new app table colume matches usual order now 2022-01-04 11:56:17 +01:00
b61b8f0d2a
fix: always check for deployed status when removing
You can't delete regardless of -f if an app is deployed, the runtime
will error out. Best just deal with this for all cases then on our side.
2022-01-04 11:38:07 +01:00
763e7b5bff
fix: use StackName for querying via Docker 2022-01-04 11:37:45 +01:00
d5ab9aedbf
docs: match other abort command outputs 2022-01-04 11:37:35 +01:00
2ebb00c9d4
docs: confirm prompt matches language of command 2022-01-04 11:37:04 +01:00
6d76b3646a
fix: use spaces like the rest [ci skip] 2022-01-03 18:41:11 +01:00
636dc82258
chore: 0.4.x rc2 2022-01-03 16:37:19 +01:00
66d5453248
docs: recommend more helper commands for deploy timeout 2022-01-03 16:33:28 +01:00
ba9abcb0d7
fix: increase converge timeout 2022-01-03 16:33:18 +01:00
a1cbf21f61
fix: handle "uknown" version on deployment
Fixes pre-deploy overview version listing.
2022-01-03 16:32:03 +01:00
bd1da39374
fix: show latest version when up-to-date 2022-01-03 16:31:30 +01:00
8b90519bc9
test: more manual test examples 2022-01-03 16:31:16 +01:00
65feda7f1d
fix: dont lookup release notes if no version passed 2022-01-03 16:14:56 +01:00
64e223a810
fix: dont display non-existant release notes if no version 2022-01-03 16:14:44 +01:00
379e01d855
fix: use installer without progress bar [ci skip]
Doesn't look well when invoked from "bash -c '...'" when we run "abra
upgrade". The progress bar shoots down the page and you miss the intro
banner.
2022-01-02 20:39:11 +01:00
a421c0dca5
test: use new name [ci skip] 2022-01-02 20:18:37 +01:00
abf56f9054
chore: publish 0.4.0-alpha-rc1 2022-01-02 20:05:53 +01:00
4dec3c4646
fix: show order as in other tables 2022-01-02 16:25:18 +01:00
c900cebc30
fix: fix filtering by type for output 2022-01-02 16:21:22 +01:00
30209de3e2
fix: correct url for commit [ci skip] 2022-01-02 16:01:03 +01:00
625747d048
fix: get right url 2022-01-02 15:54:46 +01:00
a71b070921
feat: support skipping upgrades 2022-01-02 15:46:35 +01:00
33ff04c686
fix: dont list if no volumes 2022-01-02 15:20:17 +01:00
c69a3c23c5
fix: show app arg 2022-01-02 15:19:40 +01:00
0b46909961
fix: dont output if no secrets 2022-01-02 15:19:30 +01:00
832e8e5a96
test: finish first draft of manual test plan 2022-01-02 15:19:12 +01:00
abf83aa641
test: finish first pass on core integration script 2022-01-02 15:04:49 +01:00
1df69aa259
refactor: more shuffling test infra around [ci skip] 2022-01-02 14:59:46 +01:00
7596a67ad5
refactor: refocus the script purpose 2022-01-02 14:05:02 +01:00
93c7612efc
feat: allow to only destroy remote server 2022-01-02 01:52:49 +01:00
2c78ac22e0
fix: handle missing ssh keys (pass auth) 2022-01-02 01:52:33 +01:00
13661c72ce
test: more example env vars 2022-01-02 01:52:09 +01:00
454092644a
test: debug + catalogue/recipe commands [ci skip] 2022-01-01 22:04:04 +01:00
224c0c38db
fix: setup git for e2e testing 2022-01-01 22:03:53 +01:00
560e0eab86
fix: ensure catalogue is present 2022-01-01 22:01:16 +01:00
b92fdbbd52
fix: use right arg 2022-01-01 21:46:48 +01:00
0a550363b8
fix: correctly count recipes 2022-01-01 21:46:38 +01:00
3119220c21
fix: better error 2022-01-01 21:46:24 +01:00
49f565e5db
test: start on integration script 2022-01-01 21:36:00 +01:00
94522178b1
fix: handle noinput case 2022-01-01 21:34:58 +01:00
810bc27967
fix: dont assume ipv4 exists 2022-01-01 21:34:49 +01:00
35d95fb9fb
docs: better example 2022-01-01 21:34:33 +01:00
d26fabe8ef
fix: handle zone argument correctly 2022-01-01 21:34:21 +01:00
84bf3ffa50
fix: use right variable 2022-01-01 21:34:07 +01:00
575485ec7a
refactor: more portable wget usage 2022-01-01 21:33:50 +01:00
0b17292219
fix: revert to existing tags for testing purposes [ci skip] 2022-01-01 20:52:17 +01:00
fffd8b2647
docs: add missing 'the' 2022-01-01 19:56:32 +01:00
c07128b308
refactor: drop integration tests [ci skip]
Will use script instead.
2022-01-01 19:56:24 +01:00
929ff88013
fix: handle missing versions 2022-01-01 17:37:34 +01:00
0353427c71
fix: adapt to new unkown version marker
Follows 7a0d18ceb6b0b74b892976ee6271a8f30ce530a3.
2022-01-01 17:37:10 +01:00
7a0d18ceb6
fix: show unknown insteaf of empty for missing version 2022-01-01 17:23:21 +01:00
8992050409
docs: dont metion git explicitly in user messages 2022-01-01 17:23:04 +01:00
abd094387f
fix: use scale for restarting
The other approach wasn't working. Duplicating containers on restart.
You'd end up with 2 containers per restart...
2022-01-01 17:22:35 +01:00
a556ca625b
fix: handle StackName / Name correctly 2022-01-01 17:22:19 +01:00
1b7836009f
test: spec out check tests [ci skip] 2021-12-31 17:19:30 +01:00
eb3509ab3f
refactor: drop uneccessary structs 2021-12-31 17:12:09 +01:00
87851d26f7
chore: makefile default runs more common tasks 2021-12-31 17:11:54 +01:00
c4f344b50a
refactor: move to manual dir [ci skip] 2021-12-31 16:56:18 +01:00
60e4dfd9cb
refactor!: use lowercase like the rest style 2021-12-31 16:53:58 +01:00
d957adb675
docs: update the release description 2021-12-31 16:48:03 +01:00
5254af0fe4
fix: handle no changes edge case for recipe release 2021-12-31 13:45:01 +01:00
ce96269be0
fix: more fixed for dry mode, this time tested :)
Follows 299276c383a1cd4c242db86132d555d934ea131f.
2021-12-31 13:37:03 +01:00
299276c383
fix: handle dry run output result correctly 2021-12-31 13:17:50 +01:00
866cdd1f29
feat: service name in ps output 2021-12-31 12:59:31 +01:00
95d385c420
fix: GetService & handling missing services 2021-12-31 12:49:31 +01:00
605e2553b8
docs: expand errors docs 2021-12-31 12:10:11 +01:00
1245827dff
fix: handle %s correctly 2021-12-31 12:05:40 +01:00
9bdb07463c
fix: handle filtered server list with sort 2021-12-30 02:06:04 +01:00
be26f80f03
fix: maintain sorted output 2021-12-30 01:07:21 +01:00
930ff68bb2
refactor: drop unused function 2021-12-30 00:42:37 +01:00
62441acf03
refactor: use SmallSHA 2021-12-30 00:41:21 +01:00
7460668ef4
fix: explain for single repo case too 2021-12-28 03:42:44 +01:00
047d0e6fbc
fix: working url 2021-12-28 03:42:02 +01:00
8785f66391
feat: link direct to tag 2021-12-28 03:40:18 +01:00
24882e95b4
fix: take version from sync when releasing 2021-12-28 03:40:02 +01:00
1fd0941239
refactor: improved version choice flow 2021-12-28 03:19:32 +01:00
26a11533b4
feat: link directly to new commit 2021-12-28 02:37:35 +01:00
b4f48c3c59
feat: show release notes on upgrade 2021-12-28 02:31:21 +01:00
43e68a99b0
refactor: reverse list function finally 2021-12-28 02:31:06 +01:00
bac6fb0fa8
docs: better wording 2021-12-28 02:01:50 +01:00
dc9c9715ce
fix: remove duplication 2021-12-28 02:01:43 +01:00
1f91b3bb03
fix: add prompt before publishing 2021-12-28 01:51:39 +01:00
a700aca23d
fix: add autocomplete for app run 2021-12-28 01:37:41 +01:00
5cacd09a04
refactor: remove old/non-urgen/resolved FIXMEs 2021-12-28 01:35:40 +01:00
6a98024a2b
refactor: drop old/upstream TODOs 2021-12-28 01:31:50 +01:00
e85117be22
docs: capitalistion, style 2021-12-28 01:27:58 +01:00
fb24357d38
refactor: merge top-level into one file 2021-12-28 01:26:40 +01:00
f5d2d3adf6
refactor: formatter gets own package 2021-12-28 01:24:23 +01:00
07119b0575
refactor: less files, they werent used generally 2021-12-28 01:08:44 +01:00
d2a6e35986
refactor: rename to flags 2021-12-28 01:04:51 +01:00
0aa37fcee8
refactor!: simplifying publish logic 2021-12-27 19:56:27 +01:00
eb1b6be4c5
fix: auto-config ssh urls and push to them 2021-12-27 18:06:56 +01:00
b98397144a
fix: wording 2021-12-27 18:06:46 +01:00
4c186678b8
fix: clone https url by default
Catalogue package had to be merged into the recipe package due to too
many circular import errors. Also, use https url for cloning, assume
folks don't have ssh setup by default (the whole reason for the
refactor).
2021-12-27 16:45:56 +01:00
b1d9d9d858
refactor: wording & short options 2021-12-27 16:12:29 +01:00
a06043375d
refactor: remove unused flag 2021-12-27 16:07:57 +01:00
3eef1e8587
feat: filter recipes list 2021-12-27 11:00:04 +01:00
37e48f262b
fix: better wording 2021-12-27 04:17:30 +01:00
06cc5d1cc3
fix: only update when really needed 2021-12-27 04:10:12 +01:00
c13f438580
refactor: remove old code 2021-12-27 04:03:53 +01:00
5cd4317580
fix: more performant ps'in 2021-12-27 04:00:37 +01:00
2ba1ec3df0
fix: x-platform loop output
See coop-cloud/organising#178.
2021-12-27 03:55:42 +01:00
34cdb9c9d8
fix: check for deployment when ps'in 2021-12-27 03:53:45 +01:00
9c281d8608
fix: flags for logging in 2021-12-27 03:27:05 +01:00
321ba1e0ec
fix: template without weird breakages 2021-12-27 03:14:48 +01:00
c5a74e9f6b
fix: template env files too 2021-12-26 04:38:34 +01:00
f8191ac248
refactor: go with domains as default 2021-12-26 04:24:12 +01:00
027c8a1420
fix: better recipe meta defaults 2021-12-26 04:10:50 +01:00
cdc08ae95a
fix: much hacking, maybe fixed catalogue generation 2021-12-26 04:02:40 +01:00
3f35510507
fix: runtime caching for catalogue generation 2021-12-26 04:01:02 +01:00
9f70a69bbf
feat: skip git syncing on catalogue generation 2021-12-26 03:46:26 +01:00
b0834925a3
fix: log in correctly
See coop-cloud/abra#139.
2021-12-26 03:44:29 +01:00
86d87253c5
fix: pass name correctly
Follows from 9cc2554846d29986770a63bf7d3896a654082ce6
2021-12-26 00:15:03 +01:00
17340a79da
refactor: more local var 2021-12-26 00:14:48 +01:00
779c810521
refactor: less quotes, less verbose 2021-12-26 00:14:32 +01:00
9cc2554846
fix: don't run twice 2021-12-26 00:02:46 +01:00
9a1cf258a5
fix: check published version properly
Resulted in a refactor to a new lint package.
2021-12-26 00:00:19 +01:00
ba8138079f
fix: use one function for up-to-date checks 2021-12-25 23:45:52 +01:00
8735a8f0ea
feat: lint before deploy/upgrade/rollback
See coop-cloud/organising#254.
2021-12-25 23:35:45 +01:00
a84a5bc320
feat: more robust linting
See coop-cloud/organising#254.
2021-12-25 23:22:50 +01:00
ae0e7b8e4c
fix: dont wrap for table output 2021-12-25 17:22:40 +01:00
c0caf14d74
fix: more meta for listing recipes 2021-12-25 17:17:41 +01:00
d66c558b5c
fix: dont render if no versions 2021-12-25 17:12:41 +01:00
c8541e1b9d
fix: show latest first 2021-12-25 17:12:34 +01:00
653b6c6d49
fix: autocomplete for recipe versions 2021-12-25 17:12:22 +01:00
e2c3bc35c3
fix: handle missing label 2021-12-25 17:02:47 +01:00
6937bfbb0d
fix: if no remotes, skip on 2021-12-25 16:56:21 +01:00
decfe095fe
feat: improved recipe creation 2021-12-25 16:56:20 +01:00
4283f130a2
refactor: apps -> recipes 2021-12-25 14:04:07 +01:00
3b5354b2a5
refactor: less quotes 2021-12-25 02:03:09 +01:00
14400d4ed8
fix: sync recipes from remotes 2021-12-24 16:06:29 +01:00
dddf84d92b
fix: avoid default value for idf
We could default to ~/.ssh/id_rsa but if that doesn't exist, then we'll
just be confusing people in the logs. Best is to just rely on the
ssh-agent which overrides this anyway. We will document this.

See coop-cloud/organising#277
2021-12-24 15:39:44 +01:00
fefb042716
fix: shorter timeout on deploy 2021-12-24 02:26:02 +01:00
ab8db8df64
feat: deploy --no-converge-checks & finish app errors 2021-12-24 02:23:46 +01:00
20f7a18caa
fix: add missing env file 2021-12-24 02:23:03 +01:00
58a24a50e1
WIP: app errors 2021-12-24 01:40:39 +01:00
e839f100df
fix: move that back, still wrong but less wrong 2021-12-24 01:32:42 +01:00
41a757b7ed
fix: only show when success is for sure 2021-12-24 00:44:50 +01:00
4b4298caf1
fix: better wording 2021-12-24 00:44:49 +01:00
8e8c241fdf
refactor: less quotes 2021-12-24 00:44:49 +01:00
9b8ff1ddcd
fix: get branch is now more robust 2021-12-24 00:44:44 +01:00
a85cfe40d0
WIP: app errors 2021-12-24 00:25:53 +01:00
fc29ca6fce
refactor: less quotes 2021-12-24 00:25:45 +01:00
cfb02f45ed
test: add test files 2021-12-24 00:25:33 +01:00
696172ad48
WIP: half-baked errors implementation 2021-12-23 21:45:59 +01:00
4089949a3f
fix: add state 2021-12-23 21:14:15 +01:00
a75b01e78a
fix: use app name instead 2021-12-23 19:34:50 +01:00
014d32112e
fix: ensure tags & commits are pushed 2021-12-23 02:24:43 +01:00
a7894cbda9
fix: better explanation 2021-12-23 02:10:57 +01:00
e03761f251
fix: include image too 2021-12-23 01:56:09 +01:00
190c1033e6
fix: handle skipping 2021-12-23 01:46:57 +01:00
15d1e9dee0
refactor: less quotes 2021-12-23 01:41:29 +01:00
0362928840
fix!: parse ttl correctly 2021-12-23 01:41:12 +01:00
844961d016
chore: add kawaiipunk
See coop-cloud/abra#145.
2021-12-23 01:16:36 +01:00
d0cc51b829
fix: point to correct var 2021-12-23 01:16:07 +01:00
606b5ac3e4
fix: less long ttl 2021-12-23 01:16:07 +01:00
6f1bf258b3 Fixed typo in abra ac bash output 2021-12-23 00:15:28 +00:00
7a5aa1b005
test: make them work again 2021-12-23 01:06:56 +01:00
db453f0ab1
feat: auto flag for dns 2021-12-22 20:46:50 +01:00
a07e71f7df
fix: grand ssh, provisioning, perms refactor
See coop-cloud/organising#280.
See coop-cloud/organising#273.
2021-12-22 20:08:15 +01:00
4c6d52c426
fix: clean up if things go wrong 2021-12-22 14:01:49 +01:00
327c5adef2
refactor: less quotes 2021-12-22 13:55:22 +01:00
0dc8425a27
fix: use wget, error out on missing deps
See coop-cloud/organising#280.
2021-12-22 13:54:13 +01:00
48c965bb21
refactor: less quotes 2021-12-22 02:50:16 +01:00
5513754c22
fix: push tags 2021-12-22 02:01:48 +01:00
3a27d9d9fb
fix: remove unexpanded var 2021-12-22 01:50:17 +01:00
04b58230ea
fix: release functionality working again 2021-12-22 01:36:41 +01:00
1b9097f9f3
fix: show where we're going 2021-12-22 01:36:29 +01:00
3d100093dc
refactor: readability 2021-12-22 01:36:17 +01:00
ef4383209e
fix: handle more appropriately 2021-12-22 01:18:16 +01:00
74f688350b
fix: actually call function 2021-12-22 01:03:36 +01:00
737a22aacc
refactor: less quotes 2021-12-22 01:02:43 +01:00
56a1e7f8c4
feat: stderr only for logs 2021-12-22 01:02:36 +01:00
6be2f36334
WIP app errors place holder 2021-12-22 00:48:00 +01:00
a18d0e290d
docs: more context on vol rm
See coop-cloud/organising#265.
2021-12-22 00:12:12 +01:00
7e0feec311
fix: add autocomplete for vol ls 2021-12-22 00:08:26 +01:00
29a4d05944
fix: more info on multiselect
See coop-cloud/organising#265.
2021-12-22 00:07:49 +01:00
b72bad955a
feat: no domain checks flag
See coop-cloud/organising#281.
2021-12-21 23:57:20 +01:00
e9b4541c91
fix: better explanation 2021-12-21 23:50:28 +01:00
5b1b16d64a
refactor: less quotes 2021-12-21 23:48:46 +01:00
ec7223146b
docs: better timeout error 2021-12-21 23:48:32 +01:00
fa45264ea0
refactor: the grand recipe release refactor 2021-12-21 19:25:44 +01:00
f57222d6aa
docs: improve once again, maybe clearer 2021-12-21 17:52:20 +01:00
28d10928a4
chore: go mod tidy 2021-12-21 17:50:45 +01:00
0f4da38f98
Merge remote-tracking branch 'origin/renovate/main-github.com-schollz-progressbar-v3-3.x' into main 2021-12-21 17:50:31 +01:00
11c2d1efe6 chore(deps): update module github.com/schollz/progressbar/v3 to v3.8.5 2021-12-21 08:01:41 +00:00
2b1cc9f6dd
docs: less quotes, more clarity on init 2021-12-21 02:28:14 +01:00
6100a636a6
fix: respect NoInput and avoid crashing on init 2021-12-21 02:27:25 +01:00
ddbf923338
fix: catch this case correctly 2021-12-21 02:27:06 +01:00
c1a00520dc
fix: stop if no tags in place 2021-12-21 02:08:51 +01:00
0dc4b2beef
refactor: less quotes, spacing for style 2021-12-21 02:04:56 +01:00
f75284364d
docs: better wording 2021-12-21 02:04:40 +01:00
fbc3b48d39
fix: autocomplete recipes 2021-12-21 02:04:31 +01:00
6f0d8b190d
fix: better spacing 2021-12-21 02:04:19 +01:00
fc3742212c
fix: more reliable syncing 2021-12-21 01:48:37 +01:00
fccbd7c7d7
chore: style lines 2021-12-21 01:48:21 +01:00
2457b5fe95
fix: return corrent error handling 2021-12-21 01:47:50 +01:00
72df640d99
fix: avoid that repo as well 2021-12-21 01:47:38 +01:00
ae9e66c319
docs: less quotes, different quotes 2021-12-20 01:05:51 +01:00
3589a7d56e
docs: explain tags 2021-12-20 00:59:48 +01:00
8d499c0810
fix: find local only apps 2021-12-20 00:50:09 +01:00
cb2bb3f532
docs: uppercase 2021-12-20 00:49:54 +01:00
0a903f041f
refactor: less quotes 2021-12-20 00:49:36 +01:00
053a06ccba
refactor: less quotes 2021-12-20 00:15:55 +01:00
398deec272
docs: improved recipe maintainer docs 2021-12-20 00:15:42 +01:00
bf82bc9c7f
feat: add dryflag, implement push for catalogue generate 2021-12-19 23:59:40 +01:00
217d4bc2cc
docs: rewording 2021-12-19 23:59:20 +01:00
9c8e6b63a6
refactor: match logging for dry run 2021-12-19 23:51:04 +01:00
5113db1612
refactor: centralise git commit machinery 2021-12-19 23:51:03 +01:00
66666e30b7
fix: take care of -n here 2021-12-19 23:36:03 +01:00
88d4984248
docs: wording 2021-12-19 23:29:05 +01:00
bc34be4357
chore: go mod tidy 2021-12-19 23:25:17 +01:00
3d1aa55587
Merge commit 'd999ced' into main 2021-12-19 23:24:40 +01:00
e7469acf5b
Merge commit 'b603069' into main 2021-12-19 23:24:29 +01:00
a293179e89
refactor: use config var for path 2021-12-19 23:24:10 +01:00
b912e73c5e
fix: get bar length right 2021-12-19 23:23:46 +01:00
4c66e44b3a
fix: use new recipes.json path 2021-12-19 23:17:46 +01:00
033bad3d10
fix: handle empty image meta 2021-12-19 23:14:43 +01:00
a750344653
refactor: better wording 2021-12-19 23:14:29 +01:00
f5caf5587a
refactor: fix log style and add recipe context 2021-12-19 23:08:03 +01:00
fdc9e8b5fd
refactor: improved log messages and less quotes 2021-12-19 23:02:58 +01:00
75edcabb23
fix: show progress on meta reading 2021-12-19 22:57:38 +01:00
fa0a63c11d
refactor: ensure type, drop comment 2021-12-19 22:45:08 +01:00
3d3eefb2fe
fix: bail out definitely on that error
See coop-cloud/organising#278.
2021-12-19 22:44:19 +01:00
6998a87eef
docs: more help for setting up 2021-12-19 16:33:24 +01:00
b71a379788
docs: be a little less intense 2021-12-19 16:33:15 +01:00
ba217dccbd
chore: point to new 0.4 release (coming soon) 2021-12-19 16:30:38 +01:00
45259b3266
refactor: drop comment 2021-12-19 16:29:28 +01:00
59b80d5def
refactor: make this flag more general 2021-12-19 16:26:45 +01:00
8f6e1de1a1
refactor: merge catalogue/catalogue, catalogue/generate 2021-12-19 16:26:27 +01:00
cd0d3b8892
chore: remove old test file 2021-12-19 16:20:42 +01:00
0d1f65daac
docs: add missing docstring 2021-12-19 16:19:42 +01:00
cf1b46fa61
refactor: move flags into internal/common 2021-12-19 16:18:50 +01:00
0fe0ffbafa
refactor: move flags to internal/common 2021-12-19 16:15:45 +01:00
af3def7267
chore: spacing for style 2021-12-19 16:08:28 +01:00
c7de9c0719
docs: add description 2021-12-19 16:07:41 +01:00
cf5ee4e682
refactor: put URLs into vars 2021-12-19 16:06:07 +01:00
9ddf69b988
refactor: move flag to internal/common 2021-12-19 16:01:20 +01:00
a925da8dee
docs: marker for author ack 2021-12-19 15:58:33 +01:00
06f8078866
refactor: move flag to internal/common 2021-12-19 15:57:12 +01:00
467947edf2
docs: show how to test 2021-12-19 15:57:11 +01:00
512cd9d85b
refactor: new line to follow other docs 2021-12-19 15:57:08 +01:00
b8e2d1de67
refactor: move function into web package 2021-12-19 15:57:00 +01:00
3b7a8e6498
docs: add missing docstrings 2021-12-19 15:56:59 +01:00
5bae262a79
refactor: drop this, it's working solid, less verbose 2021-12-19 15:56:52 +01:00
6ad253b866
docs: point to autocomplete 2021-12-19 15:44:09 +01:00
b603069514 chore(deps): update module github.com/docker/docker to v20.10.12 2021-12-14 08:01:21 +00:00
d999cedd97 chore(deps): update module github.com/docker/cli to v20.10.12 2021-12-14 08:01:10 +00:00
8215bb455b
fix: warn if secrets still exist 2021-12-13 12:29:26 +01:00
37ab9a9c08
fix: improve ls output
Closes coop-cloud/organising#252.
2021-12-12 17:51:58 +01:00
48dd9cdeed
fix: simplify ps output 2021-12-12 02:21:46 +01:00
d02e1f247f
fix: better version output
Closes coop-cloud/organising#253.
2021-12-12 02:16:01 +01:00
d087a60e09
Revert "fix: dont throw away changes"
This reverts commit dd0f328a657fbc46d25db8fb0924ec9b57423524.

Part of coop-cloud/organising#282.
2021-12-12 02:04:13 +01:00
48e16c414c
fix: use correct error format 2021-12-12 01:56:43 +01:00
f3e55e5023
fix: support registry login details 2021-12-12 01:52:28 +01:00
ae6adace50
refactor: autocomplete package 2021-12-12 00:17:39 +01:00
32dcddb631
fix: select containers if we find multiple 2021-12-12 00:04:37 +01:00
3dbd343600
fix: dont double append root path 2021-12-11 20:24:38 +01:00
8393f4b134
fix: log discovered paths 2021-12-11 20:24:29 +01:00
8e56607cc9
fix: use default 2021-12-11 20:13:55 +01:00
85a543afac
fix: maybe more robust gitignore checks 2021-12-11 20:11:59 +01:00
665396b679
fix: join path correctly 2021-12-11 20:01:30 +01:00
870c561fee
Revert "Revert "fix: include ignored files""
This reverts commit 9be78bc5fa623e38a5eca70afdf96b3765287763.

Attempting to fix this once again.
2021-12-11 19:53:35 +01:00
3fb43ffa2c
Revert "fix: match exact on filtering" [ci skip]
This reverts commit 2bc2f8630b85d903d452b7c02efc99ee933b2243.

This breaks other stuff. Reverting!
2021-12-09 14:12:16 +01:00
2bc2f8630b
fix: match exact on filtering 2021-12-06 01:26:04 +01:00
6094dfaf92
docs: help with dns
Closes coop-cloud/organising#274.
2021-12-05 01:45:21 +01:00
3789e56404
fix: prompt for server deletion
Closes coop-cloud/organising#275.
2021-12-05 01:39:25 +01:00
2db5378418
fix: dont add .git dirs
Closes coop-cloud/organising#276.
2021-12-05 01:30:23 +01:00
7d8f3f1fab
fix: less loose permissions, less +x
Closes coop-cloud/organising#283.
2021-12-05 01:18:31 +01:00
9be78bc5fa
Revert "fix: include ignored files"
This reverts commit aea5cc69c39eaa4460fd9d8cf0571d6c76c547ad.
2021-12-03 11:39:56 +01:00
116 changed files with 5985 additions and 4097 deletions

View File

@ -3,12 +3,12 @@ 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
image: golang:1.18
ignore: true # until we decide we all want this check
environment:
STATIC_CHECK_URL: honnef.co/go/tools/cmd/staticcheck
@ -18,12 +18,12 @@ steps:
- 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 +55,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
View File

@ -0,0 +1,4 @@
GANDI_TOKEN=...
HCLOUD_TOKEN=...
REGISTRY_PASSWORD=...
REGISTRY_USERNAME=...

12
.gitignore vendored
View File

@ -1,6 +1,8 @@
abra
.vscode/
vendor/
.envrc
dist/
*fmtcoverage.html
.e2e.env
.envrc
.vscode/
abra
dist/
tests/integration/.abra/catalogue
vendor/

10
AUTHORS.md Normal file
View 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

View File

@ -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 static build test
run:
@go run -ldflags=$(LDFLAGS) $(ABRA)

View File

@ -7,69 +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 apps, deploy them, run backup and restore operations and a whole lot of other things. Please see [docs.coopcloud.tech](https://docs.coopcloud.tech) for more extensive documentation.
`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 ❤
## Quick install
```bash
curl https://install.abra.autonomic.zone | bash
```
Or using the latest release candidate (extra experimental!):
```bash
curl https://install.abra.autonomic.zone | bash -s -- --rc
```
Source for this script is in [scripts/installer/installer](./scripts/installer/installer).
## Hacking
### Getting started
Install [direnv](https://direnv.net), run `cp .envrc.sample .envrc`, then run `direnv allow` in this directory. This will set coopcloud repos as private due to [this bug.](https://git.coopcloud.tech/coop-cloud/coopcloud.tech/issues/20#issuecomment-8201). Or you can run `go env -w GOPRIVATE=coopcloud.tech` but I'm not sure how persistent this is.
Install [Go >= 1.16](https://golang.org/doc/install) and then:
- `make build` to build
- `./abra` to run commands
- `make test` will run tests
- `make install` will install it to `$GOPATH/bin`
- `go get <package>` and `go mod tidy` to add a new dependency
Our [Drone CI configuration](.drone.yml) runs a number of sanity on each pushed commit. See the [Makefile](./Makefile) for more handy targets.
Please use the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/) for your commits so we can automate our change log.
### Versioning
We use [goreleaser](https://goreleaser.com) to help us automate releases. We use [semver](https://semver.org) for versioning all releases of the tool. While we are still in the public alpha release phase, we will maintain a `0.y.z-alpha` format. Change logs are generated from our commit logs. We are still working this out and aim to refine our release praxis as we go.
For developers, while using this `-alpha` format, the `y` part is the "major" version part. So, if you make breaking changes, you increment that and _not_ the `x` part. So, if you're on `0.1.0-alpha`, then you'd go to `0.1.1-alpha` for a backwards compatible change and `0.2.0-alpha` for a backwards incompatible change.
### Making a new release
- Change `ABRA_VERSION` to match the new tag in [`scripts`](./scripts/installer/installer) (use [semver](https://semver.org))
- Commit that change (e.g. `git commit -m 'chore: publish next tag x.y.z-alpha'`)
- Make a new tag (e.g. `git tag -a x.y.z-alpha`)
- Push the new tag (e.g. `git push && git push --tags`)
- Wait until the build finishes on [build.coopcloud.tech](https://build.coopcloud.tech/coop-cloud/abra)
- Deploy the new installer script (e.g. `cd ./scripts/installer && make`)
- Check the release worked, (e.g. `abra upgrade; abra -v`)
### Fork maintenance
#### `godotenv`
We maintain a fork of [godotenv](https://github.com/Autonomic-Cooperative/godotenv) for two features:
1. multi-line env var support
2. inline comment parsing
You can upgrade the version here by running `go get github.com/Autonomic-Cooperative/godotenv@<commit>` where `<commit>` is the
latest commit you want to pin to. We are aiming to migrate to YAML format for the environment configuration, so this should only
be a temporary thing.
#### `docker/client`
A number of modules in [pkg/upstream](./pkg/upstream) are copy/pasta'd from the upstream [docker/docker/client](https://pkg.go.dev/github.com/docker/docker/client). We had to do this because upstream are not exposing their API as public.
Please see [docs.coopcloud.tech/abra/](https://docs.coopcloud.tech/abra/) for help on install, upgrade, hacking, troubleshooting & more!

View File

@ -1,29 +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,
@ -35,5 +28,6 @@ to scaling apps up and spinning them down.
appSecretCommand,
appVolumeCommand,
appVersionCommand,
appErrorsCommand,
},
}

View File

@ -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)
}
},
}

View File

@ -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",
Usage: "Check if app is configured correctly",
Aliases: []string{"c"},
ArgsUsage: "<service>",
Usage: "Check if app is configured correctly",
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,
}

View File

@ -2,21 +2,26 @@ package app
import (
"errors"
"fmt"
"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"},
Usage: "Edit app config",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
appName := c.Args().First()
@ -31,7 +36,7 @@ var appConfigCommand = &cli.Command{
appFile, exists := files[appName]
if !exists {
logrus.Fatalf("cannot find app with name '%s'", appName)
logrus.Fatalf("cannot find app with name %s", appName)
}
ed, ok := os.LookupEnv("EDITOR")
@ -55,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,
}

View File

@ -1,32 +1,44 @@
package app
import (
"context"
"fmt"
"os"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/container"
"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 <app> myfile.txt app:/
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 <app> app:/myfile.txt .
abra app cp <domain> app:/myfile.txt .
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
@ -76,23 +88,64 @@ And if you want to copy that file back to your current working directory locally
logrus.Fatalf("%s does not exist locally?", dstPath)
}
}
err := internal.ConfigureAndCp(c, app, srcPath, dstPath, service, isToContainer)
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer)
if 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,
}
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", app.StackName(), service))
container, err := container.GetContainer(context.Background(), cl, filters, internal.NoInput)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
if isToContainer {
if _, err := os.Stat(srcPath); err != nil {
logrus.Fatalf("%s does not exist?", srcPath)
}
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(srcPath, toTarOpts)
if err != nil {
logrus.Fatal(err)
}
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), container.ID, dstPath, content, copyOpts); err != nil {
logrus.Fatal(err)
}
} else {
content, _, err := cl.CopyFromContainer(context.Background(), container.ID, srcPath)
if err != nil {
logrus.Fatal(err)
}
defer content.Close()
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
logrus.Fatal(err)
}
}
return nil
}

View File

@ -1,26 +1,29 @@
package app
import (
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"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.
@ -30,16 +33,5 @@ including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
Action: internal.DeployAction,
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,
}

141
cli/app/errors.go Normal file
View File

@ -0,0 +1,141 @@
package app
import (
"context"
"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", 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]
}

View File

@ -5,45 +5,61 @@ import (
"sort"
"strings"
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/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.
@ -52,13 +68,14 @@ in ~/.abra/) to generate a report of all your apps.
By passing the "--status/-S" flag, you can query all your servers for the
actual live deployment status. Depending on how many servers you manage, this
can take some time.
`,
Aliases: []string{"ls"},
`,
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 {
@ -69,8 +86,12 @@ 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)
var catl recipe.RecipeCatalogue
if status {
alreadySeen := make(map[string]bool)
for _, app := range apps {
if _, ok := alreadySeen[app.Server]; !ok {
@ -81,61 +102,65 @@ can take some time.
}
}
statuses := make(map[string]map[string]string)
tableCol := []string{"Server", "Type", "App Name", "Domain"}
if status {
tableCol = append(tableCol, "Status", "Version", "Updates")
statuses, err = config.GetAppStatuses(appFiles)
if err != nil {
logrus.Fatal(err)
}
}
table := abraFormatter.CreateTable(tableCol)
table.SetAutoMergeCellsByColumnIndex([]int{0})
var (
versionedAppsCount int
unversionedAppsCount int
onLatestCount int
canUpgradeCount int
)
catl, err := catalogue.ReadRecipeCatalogue()
var err error
catl, err = recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
}
var appsCount int
var totalServersCount int
var totalAppsCount int
allStats := make(map[string]serverStatus)
for _, app := range apps {
var tableRow []string
if app.Type == appType || appType == "" {
appsCount++
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 type flag is set, check for it, if not, Type == ""
tableRow = []string{app.Server, app.Type, app.StackName(), app.Domain}
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, catl)
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
@ -159,40 +184,80 @@ 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)
}
var stats string
if table.NumLines() > 0 {
table.Render()
if status {
stats = fmt.Sprintf(
"Total apps: %v | Versioned: %v | Unversioned: %v | On latest: %v | Can upgrade: %v",
appsCount,
versionedAppsCount,
unversionedAppsCount,
onLatestCount,
canUpgradeCount,
)
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 {
stats = fmt.Sprintf("Total apps: %v", appsCount)
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount))
}
}
table.SetCaption(true, stats)
table.Render()
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
},

View File

@ -1,27 +1,39 @@
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)
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 +42,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: false,
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 +58,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 +85,35 @@ var appLogsCommand = &cli.Command{
serviceName := c.Args().Get(1)
if serviceName == "" {
logrus.Debug("tailing logs for all app services")
logrus.Debugf("tailing logs for all %s services", app.Recipe)
stackLogs(c, app.StackName(), cl)
} else {
logrus.Debugf("tailing logs for %s", serviceName)
if err := tailServiceLogs(c, cl, app, serviceName); err != nil {
logrus.Fatal(err)
}
}
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: false,
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 +122,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)
}
},
}

View File

@ -1,12 +1,9 @@
package app
import (
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"coopcloud.tech/abra/pkg/autocomplete"
"github.com/urfave/cli"
)
var appNewDescription = `
@ -14,7 +11,7 @@ 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".
@ -29,30 +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{
internal.DebugFlag,
internal.NoInputFlag,
internal.NewAppServerFlag,
internal.DomainFlag,
internal.NewAppNameFlag,
internal.PassFlag,
internal.SecretsFlag,
},
ArgsUsage: "<recipe>",
Before: internal.SubCommandBefore,
ArgsUsage: "[<recipe>]",
Action: internal.NewAction,
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)
}
},
BashComplete: autocomplete.RecipeNameComplete,
}

View File

@ -1,66 +1,39 @@
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",
Description: "This command shows a more detailed status output of a specific deployed app.",
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)
@ -68,16 +41,42 @@ 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", "app name", "services"}
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 := filters.NewArgs()
filters.Add("name", app.StackName())
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
@ -87,12 +86,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),
app.StackName(),
strings.Join(containerNames, "\n"),
container.State,
dockerFormatter.DisplayablePorts(container.Ports),
}
table.Append(tableRow)
}

View File

@ -1,18 +1,19 @@
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
@ -20,32 +21,35 @@ 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")
}
}
@ -54,19 +58,17 @@ var appRemoveCommand = &cli.Command{
logrus.Fatal(err)
}
if !internal.Force {
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
logrus.Fatalf("'%s' is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
}
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.Add("name", app.StackName())
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: fs})
if err != nil {
logrus.Fatal(err)
}
@ -81,9 +83,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,
}
@ -92,8 +97,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)
}
@ -103,7 +112,7 @@ var appRemoveCommand = &cli.Command{
logrus.Info("no secrets to remove")
}
volumeListOKBody, err := cl.VolumeList(c.Context, fs)
volumeListOKBody, err := cl.VolumeList(context.Background(), fs)
volumeList := volumeListOKBody.Volumes
if err != nil {
logrus.Fatal(err)
@ -117,9 +126,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,
}
@ -127,8 +138,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)
}
@ -138,8 +150,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 {
@ -149,16 +163,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,
}

View File

@ -1,30 +1,35 @@
package app
import (
"context"
"errors"
"fmt"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
upstream "coopcloud.tech/abra/pkg/upstream/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/urfave/cli"
)
var appRestartCommand = &cli.Command{
var appRestartCommand = cli.Command{
Name: "restart",
Aliases: []string{"re"},
Usage: "Restart an app",
Aliases: []string{"R"},
ArgsUsage: "<service>",
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)
serviceName := c.Args().Get(1)
if serviceName == "" {
serviceNameShort := c.Args().Get(1)
if serviceNameShort == "" {
err := errors.New("missing service?")
internal.ShowSubcommandHelpAndError(c, err)
}
@ -34,39 +39,32 @@ var appRestartCommand = &cli.Command{
logrus.Fatal(err)
}
serviceFilter := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs()
filters.Add("name", serviceFilter)
containerOpts := types.ContainerListOptions{Filters: filters}
containers, err := cl.ContainerList(c.Context, containerOpts)
if err != nil {
logrus.Fatal(err)
}
if len(containers) != 1 {
logrus.Fatalf("expected 1 service but got %v", len(containers))
}
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
logrus.Debugf("attempting to restart %s", serviceFilter)
timeout := 30 * time.Second
if err := cl.ContainerRestart(c.Context, containers[0].ID, &timeout); err != nil {
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)
}
logrus.Infof("%s service restarted", serviceFilter)
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
},
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)
}
},
}

View File

@ -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
},
}

View File

@ -1,10 +1,12 @@
package app
import (
"context"
"fmt"
"coopcloud.tech/abra/pkg/catalogue"
"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"
@ -13,18 +15,23 @@ import (
"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,59 +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)
}
catl, err := catalogue.ReadRecipeCatalogue()
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
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 {
@ -103,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 {
@ -129,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)
}
}
@ -137,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)
@ -152,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)
}
@ -168,12 +180,12 @@ recipes.
}
if !internal.Force {
if err := internal.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)
}

View File

@ -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/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?"))
}
@ -59,19 +63,12 @@ var appRunCommand = &cli.Command{
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)
}
if len(containers) == 0 {
logrus.Fatalf("no containers matching '%s' found?", stackAndServiceName)
}
if len(containers) > 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)
}
}
},
}

View File

@ -1,41 +1,55 @@
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 +71,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 +87,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 +99,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,12 +110,17 @@ 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: "<app> <secret-name> <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.
@ -115,7 +136,7 @@ Example:
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?"))
}
@ -128,8 +149,10 @@ Example:
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)
}
}
@ -138,28 +161,54 @@ Example:
},
}
var appSecretRmCommand = &cli.Command{
// 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
}
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",
Usage: "Remove a secret",
Aliases: []string{"rm"},
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
ArgsUsage: "<app> <secret-name>",
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 a secret from an app environment.
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) != "" && allSecrets {
if c.Args().Get(1) != "" && rmAllSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
}
if c.Args().Get(1) == "" && !allSecrets {
if c.Args().Get(1) == "" && !rmAllSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
}
@ -170,52 +219,72 @@ Example:
filters := filters.NewArgs()
filters.Add("name", app.StackName())
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
secretToRm := c.Args().Get(1)
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 {
@ -224,7 +293,7 @@ var appSecretLsCommand = &cli.Command{
filters := filters.NewArgs()
filters.Add("name", app.StackName())
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: filters})
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
@ -249,29 +318,23 @@ var appSecretLsCommand = &cli.Command{
table.Append(tableRow)
}
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Warnf("no secrets stored 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,
}
var appSecretCommand = &cli.Command{
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,

View File

@ -1,19 +1,25 @@
package app
import (
"fmt"
"context"
"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/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,15 +35,15 @@ 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 := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
@ -45,22 +51,11 @@ volumes as eligiblef or pruning once undeployed.
}
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,
}

View File

@ -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"
"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,41 +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)
}
catl, err := catalogue.ReadRecipeCatalogue()
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
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 {
@ -99,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 {
@ -120,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)
}
}
@ -129,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)
@ -144,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)
}
@ -159,26 +190,15 @@ recipes.
logrus.Fatal(err)
}
if err := internal.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,
}

View File

@ -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/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,
}

View File

@ -1,77 +1,105 @@
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)
volumeList, err := client.GetVolumes(context.Background(), app.Server, app.StackName())
if err != nil {
logrus.Fatal(err)
}
table := abraFormatter.CreateTable([]string{"driver", "volume name"})
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)
volumeList, err := client.GetVolumes(context.Background(), app.Server, app.StackName())
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 +108,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,
},

View File

@ -1,121 +0,0 @@
package cli
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// downloadFile downloads a file brah
func downloadFile(filepath string, url string) (err error) {
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
}
// AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = &cli.Command{
Name: "autocomplete",
Usage: "Help set up shell autocompletion",
Aliases: []string{"ac"},
Description: `
This command helps set up autocompletion in your shell by downloading the
relevant autocompletion files and laying out what additional information must
be loaded.
Example:
abra autocomplete bash
Supported shells are as follows:
fizsh
zsh
bash
`,
ArgsUsage: "<shell>",
Action: func(c *cli.Context) error {
shellType := c.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
}
supportedShells := map[string]bool{
"bash": true,
"zsh": true,
"fizsh": true,
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fizsh" {
shellType = "zsh" // handled the same on the autocompletion side
}
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0755); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("'%s' already created, moving on...", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := downloadFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/bash/completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash/completion.d/abra" >> ~/.bashrc
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
`, autocompletionFile))
}
return nil
},
}

View File

@ -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
}

View File

@ -1,282 +0,0 @@
package catalogue
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/limit"
"github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// CatalogueSkipList is all the repos that are not recipes.
var CatalogueSkipList = map[string]bool{
"abra": true,
"abra-apps": true,
"abra-aur": true,
"abra-bash": true,
"abra-capsul": true,
"abra-gandi": true,
"abra-hetzner": true,
"apps": true,
"aur-abra-git": true,
"auto-apps-json": true,
"auto-mirror": true,
"backup-bot": true,
"backup-bot-two": true,
"comrade-renovate-bot": true,
"coopcloud.tech": true,
"coturn": true,
"docker-cp-deploy": true,
"docker-dind-bats-kcov": true,
"docs.coopcloud.tech": true,
"drone-abra": true,
"example": true,
"gardening": true,
"go-abra": true,
"organising": true,
"pyabra": true,
"radicle-seed-node": true,
"recipes": true,
"stack-ssh-deploy": true,
"swarm-cronjob": true,
"tagcmp": true,
"traefik-cert-dumper": true,
"tyop": true,
}
var commit bool
var commitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "Commits new generated catalogue changes",
Value: false,
Aliases: []string{"c"},
Destination: &commit,
}
var catalogueGenerateCommand = &cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate a new copy of the catalogue",
Flags: []cli.Flag{
internal.PushFlag,
commitFlag,
internal.CommitMessageFlag,
},
Description: `
This command generates a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README and tags to produce recipe metadata and produces a
apps.json file which is placed in your ~/.abra/catalogue/recipes.json.
It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten.
A new catalogue copy can be published to the recipes repository by passing the
"--commit" and "--push" flags. The recipes repository is available here:
https://git.coopcloud.tech/coop-cloud/recipes
`,
ArgsUsage: "[<recipe>]",
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName != "" {
internal.ValidateRecipe(c)
}
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
repos, err := catalogue.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("ensuring '%v' recipe(s) are locally present and up-to-date", len(repos))
cloneLimiter := limit.New(10)
retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes...")
ch := make(chan string, len(repos))
for _, repoMeta := range repos {
go func(rm catalogue.RepoMeta) {
cloneLimiter.Begin()
defer cloneLimiter.End()
if recipeName != "" && recipeName != rm.Name {
ch <- rm.Name
retrieveBar.Add(1)
return
}
if _, exists := CatalogueSkipList[rm.Name]; exists {
ch <- rm.Name
retrieveBar.Add(1)
return
}
recipeDir := path.Join(config.ABRA_DIR, "apps", rm.Name)
if err := gitPkg.Clone(recipeDir, rm.SSHURL); err != nil {
logrus.Fatal(err)
}
isClean, err := gitPkg.IsClean(rm.Name)
if err != nil {
logrus.Fatal(err)
}
if !isClean {
logrus.Fatalf("'%s' has locally unstaged changes", rm.Name)
}
if err := gitPkg.EnsureUpToDate(recipeDir); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
}
for range repos {
<-ch // wait for everything
}
catl := make(catalogue.RecipeCatalogue)
catlBar := formatter.CreateProgressbar(len(repos), "generating catalogue...")
for _, recipeMeta := range repos {
if recipeName != "" && recipeName != recipeMeta.Name {
catlBar.Add(1)
continue
}
if _, exists := CatalogueSkipList[recipeMeta.Name]; exists {
catlBar.Add(1)
continue
}
versions, err := catalogue.GetRecipeVersions(recipeMeta.Name)
if err != nil {
logrus.Fatal(err)
}
features, category, err := catalogue.GetRecipeFeaturesAndCategory(recipeMeta.Name)
if err != nil {
logrus.Warn(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: category,
Features: features,
}
catlBar.Add(1)
}
recipesJSON, err := json.MarshalIndent(catl, "", " ")
if err != nil {
logrus.Fatal(err)
}
if _, err := os.Stat(config.APPS_JSON); err != nil && os.IsNotExist(err) {
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil {
logrus.Fatal(err)
}
} else {
if recipeName != "" {
catlFS, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.APPS_JSON, updatedRecipesJSON, 0644); err != nil {
logrus.Fatal(err)
}
}
}
cataloguePath := path.Join(config.ABRA_DIR, "catalogue", "recipes.json")
logrus.Infof("generated new recipe catalogue in %s", cataloguePath)
if commit {
repoPath := path.Join(config.ABRA_DIR, "catalogue")
commitRepo, err := git.PlainOpen(repoPath)
if err != nil {
logrus.Fatal(err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
logrus.Fatal(err)
}
if internal.CommitMessage == "" {
prompt := &survey.Input{
Message: "commit message",
Default: "chore: publish new catalogue changes",
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err)
}
}
err = commitWorktree.AddGlob("**.json")
if err != nil {
logrus.Fatal(err)
}
logrus.Debug("staged **.json for commit")
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("changes commited")
if err := commitRepo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info("changes pushed")
}
return nil
},
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
},
}

View File

@ -2,8 +2,10 @@
package cli
import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"coopcloud.tech/abra/cli/app"
@ -12,38 +14,145 @@ import (
"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"))
}
supportedShells := map[string]bool{
"bash": true,
"zsh": true,
"fizsh": true,
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fizsh" {
shellType = "zsh" // handled the same on the autocompletion side
}
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("%s already created", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := web.GetFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app 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 🎩🐇
____ ____ _ _
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|_|
`,
`,
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []*cli.Command{
Commands: []cli.Command{
app.AppCommand,
server.ServerCommand,
recipe.RecipeCommand,
@ -52,48 +161,29 @@ func newAbraApp(version, commit string) *cli.App {
UpgradeCommand,
AutoCompleteCommand,
},
Flags: []cli.Flag{
VerboseFlag,
internal.DebugFlag,
internal.NoInputFlag,
},
Authors: []*cli.Author{
{Name: "3wordchant"},
{Name: "decentral1se"},
{Name: "knoflook"},
{Name: "roxxers"},
},
BashComplete: autocomplete.SubcommandComplete,
}
app.EnableBashCompletion = true
app.Before = func(c *cli.Context) error {
if internal.Debug {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
logrus.AddHook(logrusStack.StandardHook())
}
paths := []string{
config.ABRA_DIR,
path.Join(config.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
}

View File

@ -1,7 +1,11 @@
package internal
import (
"github.com/urfave/cli/v2"
"os"
logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
// Secrets stores the variable from SecretsFlag
@ -9,9 +13,7 @@ var Secrets bool
// SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{
Name: "secrets",
Aliases: []string{"S"},
Value: false,
Name: "secrets, S",
Usage: "Automatically generate secrets",
Destination: &Secrets,
}
@ -21,22 +23,19 @@ var Pass bool
// PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{
Name: "pass",
Aliases: []string{"P"},
Value: false,
Name: "pass, p",
Usage: "Store the generated secrets in a local pass store",
Destination: &Pass,
}
// Context is temp
var Context string
// PassRemove stores the variable for PassRemoveFlag
var PassRemove bool
// ContextFlag is temp
var ContextFlag = &cli.StringFlag{
Name: "context",
Value: "",
Aliases: []string{"c"},
Destination: &Context,
// 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.
@ -44,9 +43,8 @@ var Force bool
// ForceFlag turns on/off force functionality.
var ForceFlag = &cli.BoolFlag{
Name: "force",
Value: false,
Aliases: []string{"f"},
Name: "force, f",
Usage: "Perform action without further prompt. Use with care!",
Destination: &Force,
}
@ -55,9 +53,7 @@ var Chaos bool
// ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{
Name: "chaos",
Value: false,
Aliases: []string{"ch"},
Name: "chaos, C",
Usage: "Deploy uncommitted recipes changes. Use with care!",
Destination: &Chaos,
}
@ -67,18 +63,15 @@ var DNSProvider string
// DNSProviderFlag selects a DNS provider.
var DNSProviderFlag = &cli.StringFlag{
Name: "provider",
Name: "provider, p",
Value: "",
Aliases: []string{"p"},
Usage: "DNS provider",
Destination: &DNSProvider,
}
var NoInput bool
var NoInputFlag = &cli.BoolFlag{
Name: "no-input",
Value: false,
Aliases: []string{"n"},
Name: "no-input, n",
Usage: "Toggle non-interactive mode",
Destination: &NoInput,
}
@ -86,9 +79,8 @@ var NoInputFlag = &cli.BoolFlag{
var DNSType string
var DNSTypeFlag = &cli.StringFlag{
Name: "type",
Name: "record-type, rt",
Value: "",
Aliases: []string{"t"},
Usage: "Domain name record type (e.g. A)",
Destination: &DNSType,
}
@ -96,9 +88,8 @@ var DNSTypeFlag = &cli.StringFlag{
var DNSName string
var DNSNameFlag = &cli.StringFlag{
Name: "name",
Name: "record-name, rn",
Value: "",
Aliases: []string{"n"},
Usage: "Domain name record name (e.g. mysubdomain)",
Destination: &DNSName,
}
@ -106,29 +97,25 @@ var DNSNameFlag = &cli.StringFlag{
var DNSValue string
var DNSValueFlag = &cli.StringFlag{
Name: "value",
Name: "record-value, rv",
Value: "",
Aliases: []string{"v"},
Usage: "Domain name record value (e.g. 192.168.1.1)",
Destination: &DNSValue,
}
var DNSTTL int
var DNSTTLFlag = &cli.IntFlag{
Name: "ttl",
Value: 86400,
Aliases: []string{"T"},
Usage: "Domain name TTL value)",
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: "priority",
Name: "record-priority, rp",
Value: 10,
Aliases: []string{"P"},
Usage: "Domain name priority value",
Destination: &DNSPriority,
}
@ -136,8 +123,7 @@ var DNSPriorityFlag = &cli.IntFlag{
var ServerProvider string
var ServerProviderFlag = &cli.StringFlag{
Name: "provider",
Aliases: []string{"p"},
Name: "provider, p",
Usage: "3rd party server provider",
Destination: &ServerProvider,
}
@ -145,9 +131,8 @@ var ServerProviderFlag = &cli.StringFlag{
var CapsulInstanceURL string
var CapsulInstanceURLFlag = &cli.StringFlag{
Name: "capsul-url",
Name: "capsul-url, cu",
Value: "yolo.servers.coop",
Aliases: []string{"cu"},
Usage: "capsul instance URL",
Destination: &CapsulInstanceURL,
}
@ -155,9 +140,8 @@ var CapsulInstanceURLFlag = &cli.StringFlag{
var CapsulName string
var CapsulNameFlag = &cli.StringFlag{
Name: "capsul-name",
Name: "capsul-name, cn",
Value: "",
Aliases: []string{"cn"},
Usage: "capsul name",
Destination: &CapsulName,
}
@ -165,9 +149,8 @@ var CapsulNameFlag = &cli.StringFlag{
var CapsulType string
var CapsulTypeFlag = &cli.StringFlag{
Name: "capsul-type",
Name: "capsul-type, ct",
Value: "f1-xs",
Aliases: []string{"ct"},
Usage: "capsul type",
Destination: &CapsulType,
}
@ -175,38 +158,33 @@ var CapsulTypeFlag = &cli.StringFlag{
var CapsulImage string
var CapsulImageFlag = &cli.StringFlag{
Name: "capsul-image",
Name: "capsul-image, ci",
Value: "debian10",
Aliases: []string{"ci"},
Usage: "capsul image",
Destination: &CapsulImage,
}
var CapsulSSHKeys cli.StringSlice
var CapsulSSHKeysFlag = &cli.StringSliceFlag{
Name: "capsul-ssh-keys",
Aliases: []string{"cs"},
Name: "capsul-ssh-keys, cs",
Usage: "capsul SSH key",
Destination: &CapsulSSHKeys,
Value: &CapsulSSHKeys,
}
var CapsulAPIToken string
var CapsulAPITokenFlag = &cli.StringFlag{
Name: "capsul-token",
Aliases: []string{"ca"},
Name: "capsul-token, ca",
Usage: "capsul API token",
EnvVars: []string{"CAPSUL_TOKEN"},
EnvVar: "CAPSUL_TOKEN",
Destination: &CapsulAPIToken,
}
var HetznerCloudName string
var HetznerCloudNameFlag = &cli.StringFlag{
Name: "hetzner-name",
Name: "hetzner-name, hn",
Value: "",
Aliases: []string{"hn"},
Usage: "hetzner cloud name",
Destination: &HetznerCloudName,
}
@ -214,8 +192,7 @@ var HetznerCloudNameFlag = &cli.StringFlag{
var HetznerCloudType string
var HetznerCloudTypeFlag = &cli.StringFlag{
Name: "hetzner-type",
Aliases: []string{"ht"},
Name: "hetzner-type, ht",
Usage: "hetzner cloud type",
Destination: &HetznerCloudType,
Value: "cx11",
@ -224,8 +201,7 @@ var HetznerCloudTypeFlag = &cli.StringFlag{
var HetznerCloudImage string
var HetznerCloudImageFlag = &cli.StringFlag{
Name: "hetzner-image",
Aliases: []string{"hi"},
Name: "hetzner-image, hi",
Usage: "hetzner cloud image",
Value: "debian-10",
Destination: &HetznerCloudImage,
@ -234,17 +210,15 @@ var HetznerCloudImageFlag = &cli.StringFlag{
var HetznerCloudSSHKeys cli.StringSlice
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
Name: "hetzner-ssh-keys",
Aliases: []string{"hs"},
Name: "hetzner-ssh-keys, hs",
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
Destination: &HetznerCloudSSHKeys,
Value: &HetznerCloudSSHKeys,
}
var HetznerCloudLocation string
var HetznerCloudLocationFlag = &cli.StringFlag{
Name: "hetzner-location",
Aliases: []string{"hl"},
Name: "hetzner-location, hl",
Usage: "hetzner cloud server location",
Value: "hel1",
Destination: &HetznerCloudLocation,
@ -253,10 +227,9 @@ var HetznerCloudLocationFlag = &cli.StringFlag{
var HetznerCloudAPIToken string
var HetznerCloudAPITokenFlag = &cli.StringFlag{
Name: "hetzner-token",
Aliases: []string{"ha"},
Name: "hetzner-token, ha",
Usage: "hetzner cloud API token",
EnvVars: []string{"HCLOUD_TOKEN"},
EnvVar: "HCLOUD_TOKEN",
Destination: &HetznerCloudAPIToken,
}
@ -265,13 +238,121 @@ var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Value: false,
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.
@ -294,6 +375,10 @@ 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:
@ -316,3 +401,33 @@ Host foo.coopcloud.tech
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
}

View File

@ -1,74 +0,0 @@
package internal
import (
"fmt"
"os"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"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"
)
func ConfigureAndCp(c *cli.Context, app config.App, srcPath string, dstPath string, service string, isToContainer bool) error {
appFiles, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
appEnv, err := config.GetApp(appFiles, app.Name)
if err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
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)
if isToContainer {
if _, err := os.Stat(srcPath); err != nil {
logrus.Fatalf("'%s' does not exist?", srcPath)
}
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(srcPath, toTarOpts)
if err != nil {
logrus.Fatal(err)
}
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
logrus.Fatal(err)
}
} else {
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
if err != nil {
logrus.Fatal(err)
}
defer content.Close()
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
logrus.Fatal(err)
}
}
return nil
}

View File

@ -1,80 +1,103 @@
package internal
import (
"context"
"fmt"
"io/ioutil"
"os"
"path"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"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/v2"
"github.com/urfave/cli"
)
// DeployAction is the main command-line action for this package
func DeployAction(c *cli.Context) error {
app := ValidateApp(c)
stackName := app.StackName()
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", stackName)
logrus.Debugf("checking whether %s is already deployed", app.StackName())
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, 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)", stackName)
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name)
} else {
logrus.Fatalf("%s is already deployed", stackName)
logrus.Fatalf("%s is already deployed", app.Name)
}
}
version := deployedVersion
if version == "" && !Chaos {
catl, err := catalogue.ReadRecipeCatalogue()
if version == "unknown" && !Chaos {
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
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.Type, version); err != nil {
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
}
} else {
version = "latest commit"
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.Type); err != nil {
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
}
if version == "" && !Chaos {
if version == "unknown" && !Chaos {
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
}
}
if version != "" && !Chaos {
if err := recipe.EnsureVersion(app.Type, version); err != nil {
if version != "unknown" && !Chaos {
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
}
}
@ -82,13 +105,13 @@ func DeployAction(c *cli.Context) error {
if Chaos {
logrus.Warnf("chaos mode engaged")
var err error
version, err = recipe.ChaosVersion(app.Type)
version, 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)
@ -97,13 +120,13 @@ func DeployAction(c *cli.Context) error {
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)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Namespace: app.StackName(),
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
@ -116,17 +139,16 @@ func DeployAction(c *cli.Context) error {
logrus.Fatal(err)
}
if !NoDomainChecks {
domainName := app.Env["DOMAIN"]
ipv4, err := dns.EnsureIPv4(domainName)
if err != nil || ipv4 == "" {
logrus.Fatalf("could not find an IP address assigned to %s?", domainName)
}
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
logrus.Fatal(err)
}
} else {
logrus.Warn("skipping domain checks as requested")
}
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, DontWaitConverge); err != nil {
logrus.Fatal(err)
}
@ -135,8 +157,8 @@ func DeployAction(c *cli.Context) error {
// 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)
tableCol := []string{"server", "recipe", "config", "domain", "version"}
table := formatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -148,7 +170,7 @@ func DeployOverview(app config.App, version, message string) error {
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, version})
table.Render()
if NoInput {
@ -172,9 +194,9 @@ func DeployOverview(app config.App, version, message string) error {
}
// 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)
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 {
@ -186,9 +208,24 @@ func NewVersionOverview(app config.App, currentVersion, newVersion string) error
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
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
}
@ -208,3 +245,22 @@ func NewVersionOverview(app config.App, currentVersion, newVersion string) error
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
}

View File

@ -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

View File

@ -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
View 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
}

View File

@ -4,51 +4,28 @@ import (
"fmt"
"path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"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/v2"
"github.com/urfave/cli"
)
// AppSecrets represents all app secrest
type AppSecrets map[string]string
var Domain string
var DomainFlag = &cli.StringFlag{
Name: "domain",
Aliases: []string{"d"},
Value: "",
Usage: "Choose a domain name",
Destination: &Domain,
}
var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &NewAppServer,
}
var NewAppName string
var NewAppNameFlag = &cli.StringFlag{
Name: "app-name",
Aliases: []string{"a"},
Value: "",
Usage: "Choose an app name",
Destination: &NewAppName,
}
// RecipeName is used for configuring recipe name programmatically
var RecipeName string
// createSecrets creates all secrets for a new app.
func createSecrets(sanitisedAppName string) (AppSecrets, error) {
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", Domain))
appEnv, err := config.ReadEnv(appEnvPath)
if err != nil {
return nil, err
@ -63,7 +40,7 @@ func createSecrets(sanitisedAppName string) (AppSecrets, error) {
if Pass {
for secretName := range secrets {
secretValue := secrets[secretName]
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, NewAppServer); err != nil {
if err := secret.PassInsertSecret(secretValue, secretName, Domain, NewAppServer); err != nil {
return nil, err
}
}
@ -90,6 +67,31 @@ func ensureDomainFlag(recipe recipe.Recipe, server string) error {
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()
@ -114,30 +116,11 @@ func ensureServerFlag() error {
return nil
}
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
func ensureAppNameFlag() error {
if NewAppName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app name:",
Default: config.SanitiseAppName(Domain),
}
if err := survey.AskOne(prompt, &NewAppName); err != nil {
return err
}
}
if NewAppName == "" {
return fmt.Errorf("no app name provided")
}
return nil
}
// NewAction is the new app creation logic
func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c)
recipe := ValidateRecipeWithPrompt(c, false)
if err := config.EnsureAbraDirExists(); err != nil {
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
}
@ -149,48 +132,45 @@ func NewAction(c *cli.Context) error {
logrus.Fatal(err)
}
if err := ensureAppNameFlag(); err != nil {
if err := promptForSecrets(Domain); 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)
sanitisedAppName := config.SanitiseAppName(Domain)
logrus.Debugf("%s sanitised as %s for new app", Domain, sanitisedAppName)
if err := config.TemplateAppEnvSample(recipe.Name, NewAppName, NewAppServer, Domain, recipe.Name); err != nil {
if err := config.TemplateAppEnvSample(recipe.Name, Domain, NewAppServer, 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)
}
secrets, err := createSecrets(sanitisedAppName)
var err error
secrets, err = createSecrets(sanitisedAppName)
if err != nil {
logrus.Fatal(err)
}
secretCols := []string{"Name", "Value"}
secretTable := abraFormatter.CreateTable(secretCols)
secretTable = formatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
}
if len(secrets) > 0 {
defer secretTable.Render()
}
}
if NewAppServer == "default" {
NewAppServer = "local"
}
tableCol := []string{"Name", "Domain", "Type", "Server"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
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))
@ -198,11 +178,19 @@ func NewAction(c *cli.Context) error {
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.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", sanitisedAppName))
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
}

View File

@ -2,104 +2,55 @@ package internal
import (
"fmt"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var Major bool
var MajorFlag = &cli.BoolFlag{
Name: "major",
Usage: "Increase the major part of the version",
Value: false,
Aliases: []string{"ma", "x"},
Destination: &Major,
}
var Minor bool
var MinorFlag = &cli.BoolFlag{
Name: "minor",
Usage: "Increase the minor part of the version",
Value: false,
Aliases: []string{"mi", "y"},
Destination: &Minor,
}
var Patch bool
var PatchFlag = &cli.BoolFlag{
Name: "patch",
Usage: "Increase the patch part of the version",
Value: false,
Aliases: []string{"p", "z"},
Destination: &Patch,
}
var Dry bool
var DryFlag = &cli.BoolFlag{
Name: "dry-run",
Usage: "No changes are made, only reports changes that would be made",
Value: false,
Aliases: []string{"d"},
Destination: &Dry,
}
var Push bool
var PushFlag = &cli.BoolFlag{
Name: "push",
Usage: "Git push changes",
Value: false,
Aliases: []string{"P"},
Destination: &Push,
}
var CommitMessage string
var CommitMessageFlag = &cli.StringFlag{
Name: "commit-message",
Usage: "Commit message (implies --commit)",
Aliases: []string{"cm"},
Destination: &CommitMessage,
}
var Commit bool
var CommitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "Commits compose.**yml file changes to recipe repository",
Value: false,
Aliases: []string{"c"},
Destination: &Commit,
}
var TagMessage string
var TagMessageFlag = &cli.StringFlag{
Name: "tag-comment",
Usage: "Description for release tag",
Aliases: []string{"t", "tm"},
Destination: &TagMessage,
}
// PromptBumpType prompts for version bump type
func PromptBumpType(tagString string) error {
func PromptBumpType(tagString, latestRelease string) error {
if (!Major && !Minor && !Patch) && tagString == "" {
fmt.Printf(`
semver cheat sheet (more via semver.org):
major: new features/bug fixes, backwards incompatible
minor: new features/bug fixes, backwards compatible
patch: bug fixes, backwards compatible
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
}
@ -133,20 +84,27 @@ func SetBumpType(bumpType string) {
}
}
// GetMainApp retrieves the main 'app' image name
func GetMainApp(recipe recipe.Recipe) string {
var app string
// GetMainAppImage retrieves the main 'app' image name
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
var path string
for _, service := range recipe.Config.Services {
name := service.Name
if name == "app" {
app = strings.Split(service.Image, ":")[0]
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return "", err
}
path = reference.Path(img)
path = formatter.StripTagMeta(path)
return path, nil
}
}
if app == "" {
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
if path == "" {
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name)
}
return app
return path, nil
}

View File

@ -1,106 +0,0 @@
package internal
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureDNSProvider ensures a DNS provider is chosen.
func EnsureDNSProvider() error {
if DNSProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select DNS provider",
Options: []string{"gandi"},
}
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
return err
}
}
if DNSProvider == "" {
return fmt.Errorf("missing DNS provider?")
}
return nil
}
// EnsureDNSTypeFlag ensures a DNS type flag is present.
func EnsureDNSTypeFlag(c *cli.Context) error {
if DNSType == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record type",
Default: "A",
}
if err := survey.AskOne(prompt, &DNSType); err != nil {
return err
}
}
if DNSType == "" {
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
}
return nil
}
// EnsureDNSNameFlag ensures a DNS name flag is present.
func EnsureDNSNameFlag(c *cli.Context) error {
if DNSName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record name",
Default: "mysubdomain",
}
if err := survey.AskOne(prompt, &DNSName); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
}
return nil
}
// EnsureDNSValueFlag ensures a DNS value flag is present.
func EnsureDNSValueFlag(c *cli.Context) error {
if DNSValue == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record value",
Default: "192.168.1.2",
}
if err := survey.AskOne(prompt, &DNSValue); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
}
return nil
}
// EnsureZoneArgument ensures a zone argument is present.
func EnsureZoneArgument(c *cli.Context) (string, error) {
var zone string
if c.Args().First() == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify a domain name zone",
Default: "example.com",
}
if err := survey.AskOne(prompt, &zone); err != nil {
return zone, err
}
}
if zone == "" {
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
}
return zone, nil
}

View File

@ -1,208 +0,0 @@
package internal
import (
"fmt"
"os"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureServerProvider ensures a 3rd party server provider is chosen.
func EnsureServerProvider() error {
if ServerProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select server provider",
Options: []string{"capsul", "hetzner-cloud"},
}
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
return err
}
}
if ServerProvider == "" {
return fmt.Errorf("missing server provider?")
}
return nil
}
// EnsureNewCapsulVPSFlags ensure all flags are present.
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
if CapsulName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify capsul name",
}
if err := survey.AskOne(prompt, &CapsulName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul instance URL",
Default: CapsulInstanceURL,
}
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul type",
Default: CapsulType,
}
if err := survey.AskOne(prompt, &CapsulType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul image",
Default: CapsulImage,
}
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
return err
}
}
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify capsul SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
return err
}
CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if CapsulAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("CAPSUL_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify capsul API token",
}
if err := survey.AskOne(prompt, &CapsulAPIToken); err != nil {
return err
}
} else {
CapsulAPIToken = token
}
}
if CapsulName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul name?"))
}
if CapsulInstanceURL == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul instance url?"))
}
if CapsulType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul type?"))
}
if CapsulImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul image?"))
}
if len(CapsulSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul ssh keys?"))
}
if CapsulAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul API token?"))
}
return nil
}
// EnsureNewHetznerCloudVPSFlags ensure all flags are present.
func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
if HetznerCloudName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &HetznerCloudName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS type",
Default: HetznerCloudType,
}
if err := survey.AskOne(prompt, &HetznerCloudType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS image",
Default: HetznerCloudImage,
}
if err := survey.AskOne(prompt, &HetznerCloudImage); err != nil {
return err
}
}
if len(HetznerCloudSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify hetzner cloud SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &sshKeys); err != nil {
return err
}
HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS location",
Default: HetznerCloudLocation,
}
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
return err
}
}
if HetznerCloudAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
return err
}
} else {
HetznerCloudAPIToken = token
}
}
if HetznerCloudName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
}
if HetznerCloudType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
}
if HetznerCloudImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
}
if len(HetznerCloudSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud ssh keys?"))
}
if HetznerCloudLocation == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
}
if HetznerCloudAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
}
return nil
}

View File

@ -2,57 +2,86 @@ package internal
import (
"errors"
"fmt"
"os"
"strings"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/catalogue"
"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)
}
}
logrus.Debugf("validated '%s' as recipe argument", recipeName)
if ensureLatest {
if err := recipe.EnsureLatest(recipeName); err != nil {
logrus.Fatal(err)
}
}
return recipe
logrus.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe
}
// ValidateRecipeWithPrompt ensures a recipe argument is present before
// validating, asking for input if required.
func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
func ValidateRecipeWithPrompt(c *cli.Context, ensureLatest bool) recipe.Recipe {
recipeName := c.Args().First()
if recipeName == "" && !NoInput {
catl, err := catalogue.ReadRecipeCatalogue()
var recipes []string
catl, err := recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
var recipes []string
knownRecipes := make(map[string]bool)
for name := range catl {
recipes = append(recipes, name)
knownRecipes[name] = true
}
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,
@ -68,17 +97,23 @@ func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
}
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 {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as recipe argument", recipeName)
if ensureLatest {
if err := recipe.EnsureLatest(recipeName); err != nil {
logrus.Fatal(err)
}
}
return recipe
logrus.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe
}
// ValidateApp ensures the app name arg is valid.
@ -99,7 +134,7 @@ 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)
}
@ -107,13 +142,13 @@ func ValidateApp(c *cli.Context) config.App {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as app argument", appName)
logrus.Debugf("validated %s as app argument", appName)
return app
}
// ValidateDomain ensures the domain name arg is valid.
func ValidateDomain(c *cli.Context) (string, error) {
func ValidateDomain(c *cli.Context) string {
domainName := c.Args().First()
if domainName == "" && !NoInput {
@ -122,7 +157,7 @@ func ValidateDomain(c *cli.Context) (string, error) {
Default: "example.com",
}
if err := survey.AskOne(prompt, &domainName); err != nil {
return domainName, err
logrus.Fatal(err)
}
}
@ -130,16 +165,16 @@ func ValidateDomain(c *cli.Context) (string, error) {
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, nil
return domainName
}
// ValidateSubCmdFlags ensures flag order conforms to correct order
func ValidateSubCmdFlags(c *cli.Context) bool {
for argIdx, arg := range c.Args().Slice() {
for argIdx, arg := range c.Args() {
if !strings.HasPrefix(arg, "--") {
for _, flag := range c.Args().Slice()[argIdx:] {
for _, flag := range c.Args()[argIdx:] {
if strings.HasPrefix(flag, "--") {
return false
}
@ -150,12 +185,12 @@ func ValidateSubCmdFlags(c *cli.Context) bool {
}
// ValidateServer ensures the server name arg is valid.
func ValidateServer(c *cli.Context) (string, error) {
func ValidateServer(c *cli.Context) string {
serverName := c.Args().First()
serverNames, err := config.ReadServerNames()
if err != nil {
return serverName, err
logrus.Fatal(err)
}
if serverName == "" && !NoInput {
@ -164,15 +199,320 @@ func ValidateServer(c *cli.Context) (string, error) {
Options: serverNames,
}
if err := survey.AskOne(prompt, &serverName); err != nil {
return serverName, err
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)
logrus.Debugf("validated %s as server argument", serverName)
return serverName, nil
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
}

View File

@ -2,112 +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
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
noUnstableTags = false
}
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
},
}

View File

@ -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
},

View File

@ -1,8 +1,10 @@
package recipe
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"text/template"
@ -11,13 +13,32 @@ 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{
// 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
}
var recipeNewCommand = cli.Command{
Name: "new",
Usage: "Create a new recipe",
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.
@ -29,19 +50,17 @@ Abra uses our built-in example repository which is available here:
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).
The new example repository is cloned to ~/.abra/apps/<recipe>.
`,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
}
directory := path.Join(config.APPS_DIR, recipeName)
directory := path.Join(config.RECIPES_DIR, recipeName)
if _, err := os.Stat(directory); !os.IsNotExist(err) {
logrus.Fatalf("'%s' recipe directory already exists?", directory)
logrus.Fatalf("%s recipe directory already exists?", directory)
}
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
@ -49,49 +68,73 @@ The new example repository is cloned to ~/.abra/apps/<recipe>.
logrus.Fatal(err)
}
gitRepo := path.Join(config.APPS_DIR, recipeName, ".git")
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git")
if err := os.RemoveAll(gitRepo); err != nil {
logrus.Fatal(err)
}
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, recipeName, "README.md"),
path.Join(config.APPS_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, ".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)
}
tpl, err := template.ParseFiles(path)
if err != nil {
logrus.Fatal(err)
}
// 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
}{recipeName, "TODO"}); err != nil {
var templated bytes.Buffer
if err := tpl.Execute(&templated, meta); err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
logrus.Fatal(err)
}
newGitRepo := path.Join(config.APPS_DIR, recipeName)
}
newGitRepo := path.Join(config.RECIPES_DIR, recipeName)
if err := git.Init(newGitRepo, true); err != nil {
logrus.Fatal(err)
}
logrus.Infof(
"new recipe '%s' created in %s, happy hacking!\n",
recipeName, path.Join(config.APPS_DIR, recipeName),
)
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",
}
}

View File

@ -1,21 +1,26 @@
package recipe
import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli"
)
// 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,

View File

@ -6,290 +6,120 @@ import (
"strconv"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"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 recipeReleaseCommand = &cli.Command{
var recipeReleaseCommand = cli.Command{
Name: "release",
Usage: "Release a new recipe version",
Aliases: []string{"rl"},
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.
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.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe release gitea
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
recipe updates are properly communicated. I.e. developers of an app might
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work.
Publish your new release to git.coopcloud.tech with "-p/--publish". This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account.
`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.TagMessageFlag,
internal.PublishFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
directory := path.Join(config.APPS_DIR, recipe.Name)
tagString := c.Args().Get(1)
mainApp := internal.GetMainApp(recipe)
recipe := internal.ValidateRecipeWithPrompt(c, false)
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
if mainAppVersion == "" {
logrus.Fatalf("main 'app' service version for %s is empty?", recipe.Name)
logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
}
tagString := c.Args().Get(1)
if tagString != "" {
if _, err := tagcmp.Parse(tagString); err != nil {
logrus.Fatal("invalid tag specified")
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
}
}
if (!internal.Major && !internal.Minor && !internal.Patch) && tagString != "" {
logrus.Fatal("please specify <version> or bump type (--major/--minor/--patch)")
}
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
logrus.Fatal("cannot specify tag and bump type at the same time")
}
// bumpType is used to decide what part of the tag should be incremented
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
}
}
if err := internal.PromptBumpType(tagString); err != nil {
logrus.Fatal(err)
}
if internal.TagMessage == "" {
prompt := &survey.Input{
Message: "tag message",
Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
}
if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
logrus.Fatal(err)
}
}
var createTagOptions git.CreateTagOptions
createTagOptions.Message = internal.TagMessage
if !internal.Commit {
prompt := &survey.Confirm{
Message: "git commit changes also?",
}
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
return err
}
}
if !internal.Push {
prompt := &survey.Confirm{
Message: "git push changes also?",
}
if err := survey.AskOne(prompt, &internal.Push); err != nil {
return err
}
}
if internal.Commit || internal.CommitMessage != "" {
commitRepo, err := git.PlainOpen(directory)
if err != nil {
logrus.Fatal(err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
logrus.Fatal(err)
}
if internal.CommitMessage == "" {
prompt := &survey.Input{
Message: "commit message",
Default: fmt.Sprintf("chore: publish new %s version", internal.GetBumpType()),
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err)
}
}
err = commitWorktree.AddGlob("compose.**yml")
if err != nil {
logrus.Fatal(err)
}
logrus.Debug("staged compose.**yml for commit")
if !internal.Dry {
_, err = commitWorktree.Commit(internal.CommitMessage, &git.CommitOptions{})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("changes commited")
} else {
logrus.Info("dry run only: NOT committing changes")
}
}
repo, err := git.PlainOpen(directory)
if err != nil {
logrus.Fatal(err)
}
head, err := repo.Head()
if err != nil {
logrus.Fatal(err)
}
if tagString != "" {
tag, err := tagcmp.Parse(tagString)
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
logrus.Fatal(err)
}
}
tags, err := recipe.Tags()
if 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 internal.Dry {
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagString, hash))
return nil
}
repo.CreateTag(tagString, head.Hash(), &createTagOptions)
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
if internal.Push && !internal.Dry {
if err := repo.Push(&git.PushOptions{}); err != nil {
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
var err error
tagString, err = getLabelVersion(recipe, false)
if err != nil {
logrus.Fatal(err)
}
}
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)
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
} else {
logrus.Info("dry run only: NOT pushing changes")
}
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
return nil
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
logrus.Fatal(cleanUpErr)
}
// 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)
}
newTag := lastGitTag
var newtagString string
if bumpType > 0 {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
}
newTag.Metadata = mainAppVersion
newtagString = newTag.String()
if internal.Dry {
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newtagString, hash))
return nil
}
repo.CreateTag(newtagString, head.Hash(), &createTagOptions)
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash))
if internal.Push && !internal.Dry {
if err := repo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString))
} else {
logrus.Info("gry run only: NOT pushing changes")
}
return nil
@ -300,6 +130,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
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
@ -311,24 +142,74 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
}
path := reference.Path(img)
if strings.Contains(path, "library") {
path = strings.Split(path, "/")[1]
}
path = formatter.StripTagMeta(path)
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
logrus.Fatalf("%s service is missing image tag?", path)
if service.Name == "app" {
missingTag = true
}
continue
}
services[path] = tag
}
if missingTag {
return services, fmt.Errorf("app service is missing image tag?")
}
return services, nil
}
// 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 {
@ -337,3 +218,237 @@ func btoi(b bool) int {
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
}

View File

@ -6,51 +6,53 @@ import (
"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"},
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, 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.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe sync
Where <version> can be specifed on the command-line or Abra can attempt to
auto-generate it for you. The <recipe> configuration will be updated on the
local file system.
`,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
recipe := internal.ValidateRecipeWithPrompt(c, false)
mainApp := internal.GetMainApp(recipe)
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
}
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
tags, err := recipe.Tags()
@ -60,49 +62,75 @@ You may invoke this command in "wizard" mode and be prompted for input:
nextTag := c.Args().Get(1)
if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no tags found for %s", recipe.Name)
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) {
if err := internal.PromptBumpType(""); err != nil {
latestRelease := tags[len(tags)-1]
if err := internal.PromptBumpType("", latestRelease); err != nil {
logrus.Fatal(err)
}
}
if nextTag == "" {
recipeDir := path.Join(config.APPS_DIR, recipe.Name)
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)
@ -113,7 +141,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
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.")
logrus.Fatal("you can only use one version flag: --major, --minor or --patch")
}
}
@ -124,12 +152,14 @@ You may invoke this command in "wizard" mode and be prompted for input:
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 {
@ -137,6 +167,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
@ -153,44 +184,16 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
mainService := "app"
var services []string
hasAppService := false
for _, service := range recipe.Config.Services {
services = append(services, service.Name)
if service.Name == "app" {
hasAppService = true
logrus.Debugf("detected app service in %s", recipe.Name)
}
}
if !hasAppService {
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
}
logrus.Debugf("selecting %s as the service to sync version label", mainService)
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
if !internal.Dry {
if err := recipe.UpdateLabel(mainService, label); err != nil {
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
logrus.Fatal(err)
}
logrus.Infof("synced label '%s' to service '%s'", label, mainService)
} else {
logrus.Infof("dry run only: NOT syncing label %s for recipe %s", nextTag, recipe.Name)
logrus.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
}
return nil
},
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
},
BashComplete: autocomplete.RecipeNameComplete,
}

View File

@ -9,14 +9,16 @@ import (
"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"
)
type imgPin struct {
@ -24,10 +26,10 @@ type imgPin struct {
version tagcmp.Tag
}
var recipeUpgradeCommand = &cli.Command{
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
@ -37,19 +39,27 @@ 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{
internal.DebugFlag,
internal.NoInputFlag,
internal.PatchFlag,
internal.MinorFlag,
internal.MajorFlag,
internal.AllTagsFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
recipe := internal.ValidateRecipeWithPrompt(c, true)
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
@ -61,7 +71,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
// check for versions file and load pinned versions
versionsPresent := false
recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
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 {
@ -97,43 +107,41 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
for _, service := range recipe.Config.Services {
catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
if err != nil {
logrus.Fatal(err)
}
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]
}
semverLikeTag := true
image := reference.Path(img)
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
image = formatter.StripTagMeta(image)
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
}
@ -143,16 +151,21 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
logrus.Debugf("detected potential upgradable tags '%s' for '%s'", compatible, service.Name)
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 {
@ -165,7 +178,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
logrus.Debugf("detected compatible upgradable tags '%s' for '%s'", compatibleStrings, service.Name)
logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
var upgradeTag string
_, ok := servicePins[service.Name]
@ -182,13 +195,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if contains {
logrus.Infof("Upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
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())
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 {
@ -205,23 +218,27 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if upgradeTag == "" {
logrus.Warnf("not upgrading from '%s' to '%s' for '%s', because the upgrade type is more serious than what user wants.", tag.String(), compatible[0].String(), image)
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 {
@ -229,10 +246,17 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
}
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

View File

@ -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
},

View File

@ -1,26 +1,29 @@
package record
import (
"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"
)
// RecordListCommand lists domains.
var RecordListCommand = &cli.Command{
var RecordListCommand = cli.Command{
Name: "list",
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 for
a specific zone.
@ -46,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

View File

@ -1,26 +1,29 @@
package record
import (
"context"
"fmt"
"strconv"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/dns"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/urfave/cli"
)
// RecordNewCommand creates a new domain name record.
var RecordNewCommand = &cli.Command{
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,
@ -28,6 +31,7 @@ var RecordNewCommand = &cli.Command{
internal.DNSTTLFlag,
internal.DNSPriorityFlag,
},
Before: internal.SubCommandBefore,
Description: `
This command creates a new domain name record for a specific zone.
@ -38,7 +42,7 @@ 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
You may also invoke this command in "wizard" mode and be prompted for input:
abra record new
`,
@ -60,7 +64,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
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)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
@ -75,18 +79,23 @@ You may also invoke this command in "wizard" mode and be prompted for input
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: time.Duration(internal.DNSTTL),
TTL: ttl,
}
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
record.Priority = internal.DNSPriority
}
records, err := provider.GetRecords(c.Context, zone)
records, err := provider.GetRecords(context.Background(), zone)
if err != nil {
logrus.Fatal(err)
}
@ -95,15 +104,18 @@ You may also invoke this command in "wizard" mode and be prompted for input
if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name &&
existingRecord.Value == record.Value {
logrus.Fatal("provider library reports that this record already exists?")
logrus.Fatalf("%s record for %s already exists?", record.Type, zone)
}
}
createdRecords, err := provider.SetRecords(
c.Context,
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?")
@ -112,7 +124,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
createdRecord := createdRecords[0]
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
table := formatter.CreateTable(tableCol)
value := createdRecord.Value
if len(createdRecord.Value) > 30 {

View File

@ -1,13 +1,13 @@
package record
import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli"
)
// RecordCommand supports managing DNS entries.
var RecordCommand = &cli.Command{
var RecordCommand = cli.Command{
Name: "record",
Usage: "Manage domain name records via 3rd party providers",
Usage: "Manage domain name records",
Aliases: []string{"rc"},
ArgsUsage: "<record>",
Description: `
@ -28,9 +28,8 @@ 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{
Subcommands: []cli.Command{
RecordListCommand,
RecordNewCommand,
RecordRemoveCommand,

View File

@ -1,30 +1,34 @@
package record
import (
"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/AlecAivazis/survey/v2"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/urfave/cli"
)
// RecordRemoveCommand lists domains.
var RecordRemoveCommand = &cli.Command{
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.
@ -37,7 +41,7 @@ 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
You may also invoke this command in "wizard" mode and be prompted for input:
abra record rm
`,
@ -59,7 +63,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
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)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
@ -70,7 +74,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
logrus.Fatal(err)
}
records, err := provider.GetRecords(c.Context, zone)
records, err := provider.GetRecords(context.Background(), zone)
if err != nil {
logrus.Fatal(err)
}
@ -88,7 +92,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
table := formatter.CreateTable(tableCol)
value := toDelete.Value
if len(toDelete.Value) > 30 {
@ -105,6 +109,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
table.Render()
if !internal.NoInput {
response := false
prompt := &survey.Confirm{
Message: "continue with record deletion?",
@ -117,8 +122,9 @@ You may also invoke this command in "wizard" mode and be prompted for input
if !response {
logrus.Fatal("exiting as requested")
}
}
_, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})
_, err = provider.DeleteRecords(context.Background(), zone, []libdns.Record{toDelete})
if err != nil {
logrus.Fatal(err)
}

View File

@ -1,16 +1,15 @@
package server
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path"
"path/filepath"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
@ -23,7 +22,7 @@ import (
"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 (
@ -42,32 +41,26 @@ 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,
Name: "local, l",
Usage: "Use local server",
Destination: &local,
}
var provision bool
var provisionFlag = &cli.BoolFlag{
Name: "provision",
Aliases: []string{"p"},
Value: false,
Name: "provision, p",
Usage: "Provision server so it can deploy apps",
Destination: &provision,
}
var sshAuth string
var sshAuthFlag = &cli.StringFlag{
Name: "ssh-auth",
Aliases: []string{"sh"},
Name: "ssh-auth, s",
Value: "identity-file",
Usage: "Select SSH authentication method (identity-file, password)",
Destination: &sshAuth,
@ -75,22 +68,11 @@ var sshAuthFlag = &cli.StringFlag{
var askSudoPass bool
var askSudoPassFlag = &cli.BoolFlag{
Name: "ask-sudo-pass",
Aliases: []string{"as"},
Value: false,
Name: "ask-sudo-pass, a",
Usage: "Ask for sudo password",
Destination: &askSudoPass,
}
var traefik bool
var traefikFlag = &cli.BoolFlag{
Name: "traefik",
Aliases: []string{"t"},
Value: false,
Usage: "Deploy traefik",
Destination: &traefik,
}
func cleanUp(domainName string) {
logrus.Warnf("cleaning up context for %s", domainName)
if err := client.DeleteContext(domainName); err != nil {
@ -98,7 +80,7 @@ func cleanUp(domainName string) {
}
logrus.Warnf("cleaning up server directory for %s", domainName)
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, domainName)); err != nil {
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, domainName)); err != nil {
logrus.Fatal(err)
}
}
@ -117,7 +99,17 @@ func installDockerLocal(c *cli.Context) error {
logrus.Fatal("exiting as requested")
}
cmd := exec.Command("bash", "-c", "curl -s https://get.docker.com | bash")
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
}
@ -136,15 +128,17 @@ func newLocalServer(c *cli.Context, domainName string) error {
}
if provision {
out, err := exec.Command("which", "docker").Output()
exists, err := ensureLocalExecutable("docker")
if err != nil {
return err
}
if string(out) == "" {
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)
@ -152,12 +146,6 @@ func newLocalServer(c *cli.Context, domainName string) error {
}
}
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
return err
}
}
logrus.Info("local server has been added")
return nil
@ -195,65 +183,133 @@ func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error)
}
func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error {
result, err := sshCl.Exec("which docker")
if err != nil && string(result) != "" {
exists, err := ensureRemoteExecutable("docker", sshCl)
if err != nil {
return err
}
if string(result) == "" {
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")
}
cmd := "curl -s https://get.docker.com | bash"
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)
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.Debugf("running '%s' on %s now without sudo password", cmd, domainName)
if err := ssh.Exec(cmd, sshCl); err != nil {
return err
}
}
cmd := "wget -O- https://get.docker.com | bash"
logrus.Debugf("running %s on %s now without sudo password", cmd, domainName)
logrus.Warn("installing docker, this could take some time...")
if out, err := sshCl.Exec(cmd); err != nil {
fmt.Print(fmt.Sprintf(`
Abra was unable to bootstrap Docker, see below for logs:
%s
This could be due to a number of things but one of the most common is that your
server user account does not have sudo access, and if it does, you need to pass
"--ask-sudo-pass" in order to supply Abra with your password.
If nothing works, you try running the Docker install script manually on your server:
wget -O- https://get.docker.com | bash
`, string(out)))
logrus.Fatal(err)
}
logrus.Infof("docker is already installed on %s", domainName)
logrus.Infof("docker is installed on %s", domainName)
}
}
return nil
}
func initSwarmLocal(c *cli.Context, cl *dockerClient.Client, domainName string) error {
initReq := swarm.InitRequest{ListenAddr: "0.0.0.0:2377"}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") {
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
}
logrus.Info("swarm mode already initialised on local server")
} else {
logrus.Infof("initialised swarm mode on local server")
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if _, err := cl.NetworkCreate(context.Background(), "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
@ -275,18 +331,19 @@ func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error
ListenAddr: "0.0.0.0:2377",
AdvertiseAddr: ipv4,
}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") ||
!strings.Contains(err.Error(), "must specify a listening address") {
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
}
logrus.Infof("swarm mode already initialised on %s", domainName)
} else {
logrus.Infof("initialised swarm mode on %s", domainName)
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if _, err := cl.NetworkCreate(context.Background(), "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
@ -308,78 +365,33 @@ func createServerDir(domainName string) error {
return nil
}
func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error {
internal.NoInput = true
internal.RecipeName = "traefik"
internal.NewAppServer = domainName
internal.Domain = fmt.Sprintf("%s.%s", "traefik", domainName)
internal.NewAppName = fmt.Sprintf("%s_%s", "traefik", config.SanitiseAppName(domainName))
appEnvPath := path.Join(config.ABRA_DIR, "servers", internal.Domain, fmt.Sprintf("%s.env", internal.NewAppName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
fmt.Println(fmt.Sprintf(`
You specified "--traefik/-t" and that means that Abra will now try to
automatically create a new Traefik app on %s.
`, internal.NewAppServer))
tableCol := []string{"recipe", "domain", "server", "name"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{internal.RecipeName, internal.Domain, internal.NewAppServer, internal.NewAppName})
if err := internal.NewAction(c); err != nil {
logrus.Fatal(err)
}
} else {
logrus.Infof("%s already exists, not creating again", appEnvPath)
}
internal.AppName = internal.NewAppName
if err := internal.DeployAction(c); err != nil {
logrus.Fatal(err)
}
return nil
}
var serverAddCommand = &cli.Command{
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 can be useful when you already have a server provisioned and want
to start running Abra commands against it.
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.
This command can also provision your server ("--provision/-p") so that it is
capable of hosting Co-op Cloud apps. Abra will default to expecting that you
have a running ssh-agent and are using SSH keys to connect to your new server.
Abra will also read your SSH config (matching "Host" as <domain>). SSH
connection details precedence follows as such: command-line > SSH config >
guessed defaults.
Abra will default to expecting that you have a running ssh-agent and are using
SSH keys to connect to your new server. Abra will also read your SSH config
(matching "Host" as <domain>). SSH connection details precedence follows as
such: command-line > SSH config > guessed defaults.
If you have no SSH key configured for this host and are instead using password
authentication, you may pass "--ssh-auth password" to have Abra ask you for the
password. "--ask-sudo-pass" may be passed if you run your provisioning commands
via sudo privilege escalation.
If "--local" is passed, then Abra assumes that the current local server is
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.
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 --local
Otherwise, you may specify a remote server. The <domain> argument must be a
publicy accessible domain name which points to your server. You should have SSH
access to this server, Abra will assume port 22 and will use your current
system username to make an initial connection. You can use the <user> and
<port> arguments to adjust this.
Example:
abra server add --provision --traefik varia.zone glodemodem 12345
abra server add varia.zone glodemodem 12345 -p
Abra will construct the following SSH connection and Docker context:
@ -387,34 +399,34 @@ Abra will construct the following SSH connection and Docker context:
All communication between Abra and the server will use this SSH connection.
In this example, Abra will run the following operations:
1. Install Docker
2. Initialise Swarm mode
3. Deploy Traefik (core web proxy)
You may omit flags to avoid performing this provisioning logic.
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.
`,
Aliases: []string{"a"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
localFlag,
provisionFlag,
sshAuthFlag,
askSudoPassFlag,
traefikFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> [<user>] [<port>]",
Action: func(c *cli.Context) error {
if c.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(c) {
err := errors.New("cannot use '<domain>' and '--local' together")
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'")
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)
@ -422,11 +434,6 @@ You may omit flags to avoid performing this provisioning logic.
return nil
}
domainName, err := internal.ValidateDomain(c)
if err != nil {
logrus.Fatal(err)
}
username := c.Args().Get(1)
if username == "" {
systemUser, err := user.Current()
@ -451,14 +458,17 @@ You may omit flags to avoid performing this provisioning logic.
cl, err := newClient(c, domainName)
if err != nil {
logrus.Fatal(err)
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 {
logrus.Fatal(err)
cleanUp(domainName)
logrus.Fatalf(fmt.Sprintf(internal.ServerAddFailMsg, domainName))
}
defer sshCl.Close()
logrus.Debugf("successfully created SSH client for %s", domainName)
@ -471,17 +481,31 @@ You may omit flags to avoid performing this provisioning logic.
}
}
if _, err := cl.Info(c.Context); err != nil {
if _, err := cl.Info(context.Background()); err != nil {
cleanUp(domainName)
logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
}
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
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
}

View File

@ -3,20 +3,23 @@ package server
import (
"strings"
"coopcloud.tech/abra/cli/formatter"
"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 := context.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List()

View File

@ -1,16 +1,17 @@
package server
import (
"context"
"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"
)
func newHetznerCloudVPS(c *cli.Context) error {
@ -27,7 +28,7 @@ func newHetznerCloudVPS(c *cli.Context) error {
continue
}
sshKey, _, err := client.SSHKey.GetByName(c.Context, sshKey)
sshKey, _, err := client.SSHKey.GetByName(context.Background(), sshKey)
if err != nil {
return err
}
@ -43,13 +44,18 @@ func newHetznerCloudVPS(c *cli.Context) error {
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,
strings.Join(sshKeysRaw, "\n"),
sshKeyIDs,
internal.HetznerCloudLocation,
})
table.Render()
@ -67,7 +73,7 @@ func newHetznerCloudVPS(c *cli.Context) error {
logrus.Fatal("exiting as requested")
}
res, _, err := client.Server.Create(c.Context, serverOpts)
res, _, err := client.Server.Create(context.Background(), serverOpts)
if err != nil {
return err
}
@ -96,9 +102,18 @@ 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, ip,
))
return nil
@ -169,12 +184,21 @@ 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",
@ -196,8 +220,9 @@ API tokens are read from the environment if specified, e.g.
Where "$provider_TOKEN" is the expected env var format.
`,
ArgsUsage: "<provider>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ServerProviderFlag,
// Capsul
@ -215,6 +240,7 @@ Where "$provider_TOKEN" is the expected env var format.
internal.HetznerCloudLocationFlag,
internal.HetznerCloudAPITokenFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err)

View File

@ -1,25 +1,24 @@
package server
import (
"context"
"fmt"
"os"
"path/filepath"
"coopcloud.tech/abra/cli/formatter"
"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 rmServer bool
var rmServerFlag = &cli.BoolFlag{
Name: "server",
Aliases: []string{"s"},
Value: false,
Name: "server, s",
Usage: "remove the actual server also",
Destination: &rmServer,
}
@ -50,7 +49,7 @@ func rmHetznerCloudVPS(c *cli.Context) error {
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
server, _, err := client.Server.Get(c.Context, internal.HetznerCloudName)
server, _, err := client.Server.Get(context.Background(), internal.HetznerCloudName)
if err != nil {
return err
}
@ -89,7 +88,7 @@ destroyed.
logrus.Fatal("exiting as requested")
}
_, err = client.Server.Delete(c.Context, server)
_, err = client.Server.Delete(context.Background(), server)
if err != nil {
return err
}
@ -99,10 +98,10 @@ destroyed.
return nil
}
var serverRemoveCommand = &cli.Command{
var serverRemoveCommand = cli.Command{
Name: "remove",
Aliases: []string{"rm"},
ArgsUsage: "<server>",
ArgsUsage: "[<server>]",
Usage: "Remove a managed server",
Description: `
This command removes a server from Abra management.
@ -116,17 +115,44 @@ 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, err := internal.ValidateServer(c)
if err != nil {
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 {
@ -141,18 +167,17 @@ like tears in rain.
logrus.Fatal(err)
}
}
}
if err := client.DeleteContext(serverName); err != nil {
logrus.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, serverName)); err != nil {
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at '%s' has been lost in time, like tears in rain", serverName)
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName)
return nil
},

View File

@ -1,14 +1,14 @@
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: `
These commands support creating, managing and removing servers using 3rd party
integrations.
@ -18,7 +18,7 @@ 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,
serverAddCommand,
serverListCommand,

View File

@ -1,35 +0,0 @@
package cli
import (
"os/exec"
"coopcloud.tech/abra/cli/internal"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var RC bool
var RCFlag = &cli.BoolFlag{
Name: "rc",
Value: false,
Destination: &RC,
Usage: "Insatll the latest Release Candidate",
}
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade abra",
Flags: []cli.Flag{RCFlag},
Action: func(c *cli.Context) error {
cmd := exec.Command("bash", "-c", "curl -s https://install.abra.coopcloud.tech | bash")
if RC {
cmd = exec.Command("bash", "-c", "curl -s https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer | bash -s -- --rc")
}
logrus.Debugf("attempting to run '%s'", cmd)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
return nil
},
}

View File

@ -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"
}

35
go.mod
View File

@ -5,29 +5,31 @@ go 1.16
require (
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
github.com/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.11+incompatible
github.com/docker/distribution v2.7.1+incompatible
github.com/docker/docker v20.10.11+incompatible
github.com/docker/cli v20.10.13+incompatible
github.com/docker/distribution v2.8.1+incompatible
github.com/docker/docker v20.10.13+incompatible
github.com/docker/go-units v0.4.0
github.com/go-git/go-git/v5 v5.4.2
github.com/hetznercloud/hcloud-go v1.33.1
github.com/moby/sys/signal v0.6.0
github.com/moby/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-20211022074848-c35e78fe3f3e
github.com/Microsoft/hcsshim v0.8.21 // indirect
github.com/containerd/containerd v1.5.5 // indirect
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
@ -39,10 +41,13 @@ require (
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/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359
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
)

558
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View 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)
}
}

View File

@ -1,628 +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"
"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"`
SSO string `json:"sso"`
}
// 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 := web.NewHTTPRetryClient()
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
}
func GetStringInBetween(str, start, end string) (result string, err error) {
// GetStringInBetween returns empty string if no start or end string found
s := strings.Index(str, start)
if s == -1 {
return "", fmt.Errorf("marker string '%s' not found", start)
}
s += len(start)
e := strings.Index(str[s:], end)
if e == -1 {
return "", fmt.Errorf("end marker '%s' not found", end)
}
return str[s : s+e], nil
}
func GetImageMetadata(imageRowString string) (image, error) {
img := image{}
imgFields := strings.Split(imageRowString, ",")
for i, elem := range imgFields {
imgFields[i] = strings.TrimSpace(elem)
}
if len(imgFields) < 3 {
logrus.Warnf("image string has incorrect format: %s", imageRowString)
return img, nil
}
img.Rating = imgFields[1]
img.Source = imgFields[2]
imgString := imgFields[0]
imageName, err := GetStringInBetween(imgString, "[", "]")
if err != nil {
logrus.Fatal(err)
}
img.Image = strings.ReplaceAll(imageName, "`", "")
imageURL, err := GetStringInBetween(imgString, "(", ")")
if err != nil {
logrus.Fatal(err)
}
img.URL = imageURL
return img, nil
}
func GetRecipeFeaturesAndCategory(recipeName string) (features, string, error) {
feat := features{}
var category string
readmePath := path.Join(config.ABRA_DIR, "apps", recipeName, "README.md")
logrus.Debugf("attempting to open '%s'", readmePath)
readmeFS, err := ioutil.ReadFile(readmePath)
if err != nil {
return feat, category, err
}
readmeMetadata, err := GetStringInBetween( // Find text between delimiters
string(readmeFS),
"<!-- metadata -->", "<!-- endmetadata -->",
)
if err != nil {
return feat, category, err
}
readmeLines := strings.Split( // Array item from lines
strings.ReplaceAll( // Remove \t tabs
readmeMetadata, "\t", "",
),
"\n")
for _, val := range readmeLines {
if strings.Contains(val, "**Category**") {
category = strings.TrimSpace(
strings.TrimPrefix(val, "* **Category**:"),
)
}
if strings.Contains(val, "**Backups**") {
feat.Backups = strings.TrimSpace(
strings.TrimPrefix(val, "* **Backups**:"),
)
}
if strings.Contains(val, "**Email**") {
feat.Email = strings.TrimSpace(
strings.TrimPrefix(val, "* **Email**:"),
)
}
if strings.Contains(val, "**SSO**") {
feat.SSO = strings.TrimSpace(
strings.TrimPrefix(val, "* **SSO**:"),
)
}
if strings.Contains(val, "**Healthcheck**") {
feat.Healthcheck = strings.TrimSpace(
strings.TrimPrefix(val, "* **Healthcheck**:"),
)
}
if strings.Contains(val, "**Tests**") {
feat.Tests = strings.TrimSpace(
strings.TrimPrefix(val, "* **Tests**:"),
)
}
if strings.Contains(val, "**Image**") {
imageMetadata, err := GetImageMetadata(strings.TrimSpace(
strings.TrimPrefix(val, "* **Image**:"),
))
if err != nil {
continue
}
feat.Image = imageMetadata
}
}
return feat, category, 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,
Keep: 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]
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
logrus.Warnf("%s service is missing image tag?", path)
continue
}
logrus.Debugf("looking up image: '%s' from '%s'", img, path)
digest, err := client.GetTagDigest(img)
if err != nil {
logrus.Warn(err)
continue
}
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,
Keep: 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, catl RecipeCatalogue) ([]string, error) {
var versions []string
if recipeMeta, exists := catl[recipeName]; exists {
for _, versionMeta := range recipeMeta.Versions {
for tag := range versionMeta {
versions = append(versions, tag)
}
}
}
return versions, nil
}

View File

@ -27,7 +27,11 @@ func New(contextName string) (*client.Client, error) {
return nil, err
}
helper := commandconnPkg.NewConnectionHelper(ctxEndpoint)
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint)
if err != nil {
return nil, err
}
httpClient := &http.Client{
// No tls, no proxy
Transport: &http.Transport{
@ -55,7 +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
}

View File

@ -26,7 +26,7 @@ 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
}
@ -72,8 +72,6 @@ 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 {

View File

@ -1,171 +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/hashicorp/go-retryablehttp"
"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
type RawTags []RawTag
ref, err := docker.ParseReference(fmt.Sprintf("//%s", img))
if err != nil {
return tags, fmt.Errorf("failed to parse image %s, saw: %s", img, err.Error())
}
var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags"
func GetRegistryTags(image string) (RawTags, error) {
var tags RawTags
tagsUrl := fmt.Sprintf(registryURL, image)
if err := web.ReadJSON(tagsUrl, &tags); err != nil {
ctx := context.Background()
tags, err = docker.GetRepositoryTags(ctx, &types.SystemContext{}, ref)
if err != nil {
return tags, err
}
return tags, nil
}
// 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 := retryablehttp.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 := web.NewHTTPRetryClient()
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 := retryablehttp.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 := web.NewHTTPRetryClient()
res, err := client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
_, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
registryResT1 := struct {
SchemaVersion int
MediaType string
Manifests []struct {
MediaType string
Size int
Digest string
Platform struct {
Architecture string
Os string
}
}
}{}
registryResT2 := struct {
SchemaVersion int
MediaType string
Config struct {
MediaType string
Size int
Digest string
}
Layers []struct {
MediaType string
Size int
Digest string
}
}{}
if err := json.Unmarshal(body, &registryResT1); 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, &registryResT2); 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
}

View File

@ -5,11 +5,9 @@ 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) {
cl, err := New(server)
if err != nil {
return nil, err
@ -21,7 +19,7 @@ func GetVolumes(ctx context.Context, server string, appName string) ([]*types.Vo
volumeListOKBody, err := cl.VolumeList(ctx, fs)
volumeList := volumeListOKBody.Volumes
if err != nil {
logrus.Fatal(err)
return volumeList, err
}
return volumeList, nil
@ -29,9 +27,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 +40,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
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"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"
@ -16,26 +17,26 @@ import (
)
// 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
@ -130,19 +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)
if old == label {
logrus.Warnf("%s is already set, nothing to do?", label)
return nil
}
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
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, cannot continue...")
logrus.Fatalf("add '%s' manually, automagic insertion not supported yet", label)
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

View File

@ -1,14 +1,14 @@
package config
import (
"errors"
"fmt"
"html/template"
"io/ioutil"
"os"
"path"
"strings"
"coopcloud.tech/abra/cli/formatter"
"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"
@ -36,25 +36,33 @@ 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
// ByServer sort a slice of Apps
type ByServer []App
@ -64,25 +72,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 +105,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 +120,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 +147,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 +180,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 +220,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 +260,39 @@ 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
}
if err := tpl.Execute(file, struct{ Name string }{recipeName}); err != nil {
return err
}
logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath)
return nil
}
@ -315,9 +338,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 +345,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 +357,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 +384,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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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"],
)
}
}

View 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
}

View File

@ -47,8 +47,6 @@ func EnsureIPv4(domainName string) (string, error) {
},
}
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
ctx := context.Background()
ips, err := resolver.LookupIPAddr(ctx, domainName)
if err != nil {
@ -60,7 +58,7 @@ func EnsureIPv4(domainName string) (string, error) {
}
ipv4 = ips[0].IP.To4().String()
logrus.Debugf("discovered the following ipv4 addr: %s", ipv4)
logrus.Debugf("%s points to %s (resolver: %s)", domainName, ipv4, freifunkDNS)
return ipv4, nil
}
@ -94,3 +92,12 @@ func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
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
}

View File

@ -1,25 +1,20 @@
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]
}
@ -39,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
}
@ -54,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
View 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
}

View File

@ -7,7 +7,6 @@ import (
"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"
)
@ -15,10 +14,10 @@ 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,
@ -32,77 +31,10 @@ func Clone(dir, url string) error {
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
}
recipeName := filepath.Base(dir)
isClean, err := IsClean(recipeName)
if err != nil {
return err
}
if !isClean {
return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
}
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,
Keep: 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
View 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
}

View File

@ -24,7 +24,7 @@ func Init(repoPath string, commit bool) error {
logrus.Fatal(err)
}
if err := commitWorktree.AddGlob("**"); err != nil {
if err := commitWorktree.AddWithOptions(&git.AddOptions{All: true}); err != nil {
return err
}

43
pkg/git/push.go Normal file
View 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
}

View File

@ -2,6 +2,7 @@ package git
import (
"io/ioutil"
"os"
"os/user"
"path"
"path/filepath"
@ -17,7 +18,7 @@ import (
// 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 {
@ -33,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
}
@ -50,7 +49,10 @@ func IsClean(recipeName string) (bool, error) {
if err != nil {
return false, err
}
if len(patterns) > 0 {
worktree.Excludes = append(patterns, worktree.Excludes...)
}
status, err := worktree.Status()
if err != nil {
@ -58,15 +60,15 @@ func IsClean(recipeName string) (bool, error) {
}
if status.String() != "" {
logrus.Debugf("discovered git status for %s repository: %s", recipeName, status.String())
logrus.Debugf("discovered git status in %s: %s", repoPath, status.String())
} else {
logrus.Debugf("discovered clean git status for %s repository", recipeName)
logrus.Debugf("discovered clean git status in %s", repoPath)
}
return status.IsClean(), nil
}
// GetExcludesFiles reads the exlude files from a global git ignore
// GetExcludesFiles reads the exlude files from a global gitignore
func GetExcludesFiles() ([]gitignore.Pattern, error) {
var err error
var patterns []gitignore.Pattern
@ -93,7 +95,16 @@ func parseGitConfig() (*gitConfigPkg.Config, error) {
return nil, err
}
b, err := ioutil.ReadFile(usr.HomeDir + "/.gitconfig")
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
}
@ -115,27 +126,41 @@ func getExcludesFile(cfg *gitConfigPkg.Config) string {
}
}
}
return ""
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 ps []gitignore.Pattern
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
}
@ -143,11 +168,13 @@ 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)
@ -155,5 +182,6 @@ func expandTilde(path string) (string, error) {
paths = append(paths, p)
}
}
return "/" + filepath.Join(paths...), nil
return filepath.Join(paths...), nil
}

28
pkg/git/remote.go Normal file
View 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
}

View 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")
}
}

338
pkg/lint/recipe.go Normal file
View 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

View File

@ -19,13 +19,13 @@ func PassInsertSecret(secretValue, secretName, appName, server string) error {
secretValue, server, appName, secretName,
)
logrus.Debugf("attempting to run '%s'", cmd)
logrus.Debugf("attempting to run %s", cmd)
if err := exec.Command("bash", "-c", cmd).Run(); err != nil {
return err
}
logrus.Infof("'%s' inserted into pass store", secretName)
logrus.Infof("%s inserted into pass store", secretName)
return nil
}
@ -41,13 +41,13 @@ func PassRmSecret(secretName, appName, server string) error {
server, appName, secretName,
)
logrus.Debugf("attempting to run '%s'", cmd)
logrus.Debugf("attempting to run %s", cmd)
if err := exec.Command("bash", "-c", cmd).Run(); err != nil {
return err
}
logrus.Infof("'%s' removed from pass store", secretName)
logrus.Infof("%s removed from pass store", secretName)
return nil
}

View File

@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
@ -34,7 +35,7 @@ func GeneratePasswords(count, length uint) ([]string, error) {
return nil, err
}
logrus.Debugf("generated '%s'", strings.Join(passwords, ", "))
logrus.Debugf("generated %s", strings.Join(passwords, ", "))
return passwords, nil
}
@ -53,7 +54,7 @@ func GeneratePassphrases(count uint) ([]string, error) {
return nil, err
}
logrus.Debugf("generated '%s'", strings.Join(passphrases, ", "))
logrus.Debugf("generated %s", strings.Join(passphrases, ", "))
return passphrases, nil
}
@ -69,35 +70,32 @@ func ReadSecretEnvVars(appEnv config.AppEnv) map[string]string {
}
}
logrus.Debugf("read '%s' as secrets from '%s'", secretEnvVars, appEnv)
logrus.Debugf("read %s as secrets from %s", secretEnvVars, appEnv)
return secretEnvVars
}
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarName(secretEnvVar string) string {
withoutPrefix := strings.TrimPrefix(secretEnvVar, "SECRET_")
withoutSuffix := strings.TrimSuffix(withoutPrefix, "_VERSION")
name := strings.ToLower(withoutSuffix)
logrus.Debugf("parsed '%s' as name from '%s'", name, secretEnvVar)
logrus.Debugf("parsed %s as name from %s", name, secretEnvVar)
return name
}
// TODO: should probably go in the config/app package?
func ParseGeneratedSecretName(secret string, appEnv config.App) string {
name := fmt.Sprintf("%s_", appEnv.StackName())
withoutAppName := strings.TrimPrefix(secret, name)
idx := strings.LastIndex(withoutAppName, "_")
parsed := withoutAppName[:idx]
logrus.Debugf("parsed '%s' as name from '%s'", parsed, secret)
logrus.Debugf("parsed %s as name from %s", parsed, secret)
return parsed
}
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarValue(secret string) (secretValue, error) {
values := strings.Split(secret, "#")
if len(values) == 0 {
return secretValue{}, fmt.Errorf("unable to parse '%s'", secret)
return secretValue{}, fmt.Errorf("unable to parse %s", secret)
}
if len(values) == 1 {
@ -113,7 +111,7 @@ func ParseSecretEnvVarValue(secret string) (secretValue, error) {
}
version := strings.ReplaceAll(values[0], " ", "")
logrus.Debugf("parsed version '%s' and length '%v' from '%s'", version, length, secret)
logrus.Debugf("parsed version %s and length '%v' from %s", version, length, secret)
return secretValue{Version: version, Length: length}, nil
}
@ -122,27 +120,44 @@ func ParseSecretEnvVarValue(secret string) (secretValue, error) {
func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (map[string]string, error) {
secrets := make(map[string]string)
var mutex sync.Mutex
var wg sync.WaitGroup
ch := make(chan error, len(secretEnvVars))
for secretEnvVar := range secretEnvVars {
wg.Add(1)
go func(s string) {
defer wg.Done()
secretName := ParseSecretEnvVarName(s)
secretValue, err := ParseSecretEnvVarValue(secretEnvVars[s])
if err != nil {
ch <- err
return
}
secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secretValue.Version)
logrus.Debugf("attempting to generate and store '%s' on '%s'", secretRemoteName, server)
logrus.Debugf("attempting to generate and store %s on %s", secretRemoteName, server)
if secretValue.Length > 0 {
passwords, err := GeneratePasswords(1, uint(secretValue.Length))
if err != nil {
ch <- err
return
}
if err := client.StoreSecret(secretRemoteName, passwords[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") {
logrus.Warnf("%s already exists, moving on...", secretRemoteName)
ch <- nil
} else {
ch <- err
}
return
}
mutex.Lock()
defer mutex.Unlock()
secrets[secretName] = passwords[0]
} else {
passphrases, err := GeneratePassphrases(1)
@ -150,15 +165,27 @@ func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (m
ch <- err
return
}
if err := client.StoreSecret(secretRemoteName, passphrases[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") {
logrus.Warnf("%s already exists, moving on...", secretRemoteName)
ch <- nil
} else {
ch <- err
}
return
}
mutex.Lock()
defer mutex.Unlock()
secrets[secretName] = passphrases[0]
}
ch <- nil
}(secretEnvVar)
}
wg.Wait()
for range secretEnvVars {
err := <-ch
if err != nil {
@ -166,7 +193,7 @@ func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (m
}
}
logrus.Debugf("generated and stored '%s' on '%s'", secrets, server)
logrus.Debugf("generated and stored %s on %s", secrets, server)
return secrets, nil
}

View File

@ -12,7 +12,7 @@ import (
func CreateServerDir(serverName string) error {
serverPath := path.Join(config.ABRA_DIR, "servers", serverName)
if err := os.Mkdir(serverPath, 0755); err != nil {
if err := os.Mkdir(serverPath, 0764); err != nil {
if !os.IsExist(err) {
return err
}

78
pkg/service/service.go Normal file
View File

@ -0,0 +1,78 @@
package service
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/api/types/swarm"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// GetService retrieves a service container. If prompt is true and the retrievd
// count of service containers does not match 1, then a prompt is presented to
// let the user choose. A count of 0 is handled gracefully.
func GetService(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (swarm.Service, error) {
serviceOpts := types.ServiceListOptions{Filters: filters}
services, err := cl.ServiceList(c, serviceOpts)
if err != nil {
return swarm.Service{}, err
}
if len(services) == 0 {
filter := filters.Get("name")[0]
return swarm.Service{}, fmt.Errorf("no services matching the %v filter found?", filter)
}
if len(services) != 1 {
var servicesRaw []string
for _, service := range services {
serviceName := service.Spec.Name
created := formatter.HumanDuration(service.CreatedAt.Unix())
servicesRaw = append(servicesRaw, fmt.Sprintf("%s (created %v)", serviceName, created))
}
if !prompt {
err := fmt.Errorf("expected 1 service but found %v: %s", len(services), strings.Join(servicesRaw, " "))
return swarm.Service{}, err
}
logrus.Warnf("ambiguous service list received, prompting for input")
var response string
prompt := &survey.Select{
Message: "which service are you looking for?",
Options: servicesRaw,
}
if err := survey.AskOne(prompt, &response); err != nil {
return swarm.Service{}, err
}
chosenService := strings.TrimSpace(strings.Split(response, " ")[0])
for _, service := range services {
serviceName := strings.ToLower(service.Spec.Name)
if serviceName == chosenService {
return service, nil
}
}
logrus.Panic("failed to match chosen service")
}
return services[0], nil
}
// ContainerToServiceName converts a container name to a service name.
func ContainerToServiceName(containerNames []string, stackName string) string {
containerName := strings.Join(containerNames, "")
trimmed := strings.TrimPrefix(containerName, "/")
stackNameServiceName := strings.Split(trimmed, ".")[0]
splitter := fmt.Sprintf("%s_", stackName)
return strings.Split(stackNameServiceName, splitter)[1]
}

View File

@ -111,7 +111,7 @@ type sudoWriter struct {
// Write satisfies the write interface for sudoWriter
func (w *sudoWriter) Write(p []byte) (int, error) {
if string(p) == "sudo_password" {
if strings.Contains(string(p), "sudo_password") {
w.stdin.Write([]byte(w.pw + "\n"))
w.pw = ""
return len(p), nil
@ -131,11 +131,9 @@ func RunSudoCmd(cmd, passwd string, cl *Client) error {
}
defer session.Close()
cmd = "sudo -p " + "sudo_password" + " -S " + cmd
sudoCmd := fmt.Sprintf("SSH_ASKPASS=/usr/bin/ssh-askpass; sudo -p sudo_password -S %s", cmd)
w := &sudoWriter{
pw: passwd,
}
w := &sudoWriter{pw: passwd}
w.stdin, err = session.StdinPipe()
if err != nil {
return err
@ -144,79 +142,19 @@ func RunSudoCmd(cmd, passwd string, cl *Client) error {
session.Stdout = w
session.Stderr = w
done := make(chan struct{})
scanner := bufio.NewScanner(session.Stdin)
go func() {
for scanner.Scan() {
line := scanner.Text()
fmt.Println(line)
}
done <- struct{}{}
}()
if err := session.Start(cmd); err != nil {
return err
modes := ssh.TerminalModes{
ssh.ECHO: 0,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
<-done
if err := session.Wait(); err != nil {
return err
}
return err
}
// Exec runs a command on a remote and streams output
func Exec(cmd string, cl *Client) error {
session, err := cl.SSHClient.NewSession()
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
err = session.RequestPty("xterm", 80, 40, modes)
if err != nil {
return err
}
stderr, err := session.StdoutPipe()
if err != nil {
return err
}
stdoutDone := make(chan struct{})
stdoutScanner := bufio.NewScanner(stdout)
go func() {
for stdoutScanner.Scan() {
line := stdoutScanner.Text()
fmt.Println(line)
}
stdoutDone <- struct{}{}
}()
stderrDone := make(chan struct{})
stderrScanner := bufio.NewScanner(stderr)
go func() {
for stderrScanner.Scan() {
line := stderrScanner.Text()
fmt.Println(line)
}
stderrDone <- struct{}{}
}()
if err := session.Start(cmd); err != nil {
return err
}
<-stdoutDone
<-stderrDone
if err := session.Wait(); err != nil {
return err
if err := session.Run(sudoCmd); err != nil {
return fmt.Errorf("%s", string(w.b.Bytes()))
}
return nil
@ -320,7 +258,7 @@ func HostKeyAddCallback(hostnameAndPort string, remote net.Addr, pubKey ssh.Publ
if exists {
hostname := strings.Split(hostnameAndPort, ":")[0]
logrus.Debugf("server SSH host key found for %s, moving on", hostname)
logrus.Debugf("server SSH host key found for %s", hostname)
return nil
}
@ -387,7 +325,7 @@ func connect(username, host, port string, authMethod ssh.AuthMethod, timeout tim
conn, err = net.DialTimeout("tcp", hostnameAndPort, timeout)
if err != nil {
logrus.Debugf("tcp dialing %s failed, trying via ~/.ssh/config", hostnameAndPort)
hostConfig, err := GetHostConfig(host, username, port)
hostConfig, err := GetHostConfig(host, username, port, true)
if err != nil {
return nil, err
}
@ -514,7 +452,7 @@ func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) {
}
}
hostConfig, err := GetHostConfig(serverName, "", "")
hostConfig, err := GetHostConfig(serverName, "", "", false)
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
@ -534,45 +472,51 @@ func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) {
}
// GetHostConfig retrieves a ~/.ssh/config config for a host.
func GetHostConfig(hostname, username, port string) (HostConfig, error) {
func GetHostConfig(hostname, username, port string, override bool) (HostConfig, error) {
var hostConfig HostConfig
var host, idf string
if host = ssh_config.Get(hostname, "Hostname"); host == "" {
logrus.Debugf("no hostname found in SSH config, assuming %s", hostname)
host = hostname
if hostname == "" || override {
if sshHost := ssh_config.Get(hostname, "Hostname"); sshHost != "" {
hostname = sshHost
}
}
if username == "" {
if username = ssh_config.Get(hostname, "User"); username == "" {
if username == "" || override {
if sshUser := ssh_config.Get(hostname, "User"); sshUser != "" {
username = sshUser
} else {
systemUser, err := user.Current()
if err != nil {
return hostConfig, err
}
logrus.Debugf("no username found in SSH config or passed on command-line, assuming %s", username)
username = systemUser.Username
}
}
if port == "" {
if port = ssh_config.Get(hostname, "Port"); port == "" {
logrus.Debugf("no port found in SSH config or passed on command-line, assuming 22")
port = "22"
if port == "" || override {
if sshPort := ssh_config.Get(hostname, "Port"); sshPort != "" {
// skip override probably correct port with dummy default value from
// ssh_config which is 22. only when the original port number is empty
// should we try this default. this might not cover all cases
// unfortunately.
if port != "" && sshPort != "22" {
port = sshPort
}
}
}
idf = ssh_config.Get(hostname, "IdentityFile")
if idf != "" {
if idf := ssh_config.Get(hostname, "IdentityFile"); idf != "" && idf != "~/.ssh/identity" {
var err error
idf, err = identityFileAbsPath(idf)
if err != nil {
return hostConfig, err
}
hostConfig.IdentityFile = idf
} else {
hostConfig.IdentityFile = ""
}
hostConfig.Host = host
hostConfig.Host = hostname
hostConfig.Port = port
hostConfig.User = username

View File

@ -188,14 +188,14 @@ func ignorableCloseError(err error) bool {
func (c *commandConn) CloseRead() error {
// NOTE: maybe already closed here
if err := c.stdout.Close(); err != nil && !ignorableCloseError(err) {
// TODO: muted because https://github.com/docker/compose/issues/8544
// muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseRead: %v", err)
}
c.stdioClosedMu.Lock()
c.stdoutClosed = true
c.stdioClosedMu.Unlock()
if err := c.killIfStdioClosed(); err != nil {
// TODO: muted because https://github.com/docker/compose/issues/8544
// muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseRead: %v", err)
}
return nil
@ -212,14 +212,14 @@ func (c *commandConn) Read(p []byte) (int, error) {
func (c *commandConn) CloseWrite() error {
// NOTE: maybe already closed here
if err := c.stdin.Close(); err != nil && !ignorableCloseError(err) {
// TODO: muted because https://github.com/docker/compose/issues/8544
// muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseWrite: %v", err)
}
c.stdioClosedMu.Lock()
c.stdinClosed = true
c.stdioClosedMu.Unlock()
if err := c.killIfStdioClosed(); err != nil {
// TODO: muted because https://github.com/docker/compose/issues/8544
// muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseWrite: %v", err)
}
return nil
@ -239,7 +239,7 @@ func (c *commandConn) Close() error {
logrus.Warnf("commandConn.Close: CloseRead: %v", err)
}
if err = c.CloseWrite(); err != nil {
// TODO: muted because https://github.com/docker/compose/issues/8544
// muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.Close: CloseWrite: %v", err)
}
return err

View File

@ -44,6 +44,7 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.Conne
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
false,
)
if err != nil {
return nil, err
@ -66,13 +67,13 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.Conne
return nil, err
}
func NewConnectionHelper(daemonURL string) *connhelper.ConnectionHelper {
func NewConnectionHelper(daemonURL string) (*connhelper.ConnectionHelper, error) {
helper, err := GetConnectionHelper(daemonURL)
if err != nil {
logrus.Fatal(err)
return nil, err
}
return helper
return helper, nil
}
func getDockerEndpoint(host string) (docker.Endpoint, error) {

View File

@ -16,7 +16,6 @@ import (
)
// The default escape key sequence: ctrl-p, ctrl-q
// TODO: This could be moved to `pkg/term`.
var defaultEscapeKeys = []byte{16, 17}
// A hijackedIOStreamer handles copying input to and output from streams to the

View File

@ -399,7 +399,6 @@ func convertServiceNetworks(
return nets, nil
}
// TODO: fix secrets API so that SecretAPIClient is not required here
func convertServiceSecrets(
client client.SecretAPIClient,
namespace Namespace,
@ -421,6 +420,12 @@ func convertServiceSecrets(
return nil, err
}
// NOTE(d1): strip # length=... modifiers
if strings.Contains(obj.Name, "#") {
vals := strings.Split(obj.Name, "#")
obj.Name = strings.TrimSpace(vals[0])
}
file := swarm.SecretReferenceFileTarget(obj.File)
refs = append(refs, &swarm.SecretReference{
File: &file,
@ -442,8 +447,6 @@ func convertServiceSecrets(
// required by the serivce. Unlike convertServiceSecrets, this takes the whole
// ServiceConfig, because some Configs may be needed as a result of other
// fields (like CredentialSpecs).
//
// TODO: fix configs API so that ConfigsAPIClient is not required here
func convertServiceConfigObjs(
client client.ConfigAPIClient,
namespace Namespace,
@ -626,7 +629,6 @@ func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container
}
func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) {
// TODO: log if restart is being ignored
if source == nil {
policy, err := opts.ParseRestartPolicy(restart)
if err != nil {

View File

@ -0,0 +1,44 @@
package upstream
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// RunServiceScale scales a service (useful for restart action)
func RunServiceScale(ctx context.Context, cl *client.Client, serviceID string, scale uint64) error {
service, _, err := cl.ServiceInspectWithRaw(ctx, serviceID, types.ServiceInspectOptions{})
if err != nil {
return err
}
serviceMode := &service.Spec.Mode
if serviceMode.Replicated != nil {
serviceMode.Replicated.Replicas = &scale
} else if serviceMode.ReplicatedJob != nil {
serviceMode.ReplicatedJob.TotalCompletions = &scale
} else {
return fmt.Errorf("scale can only be used with replicated or replicated-job mode")
}
response, err := cl.ServiceUpdate(
ctx,
service.ID,
service.Version,
service.Spec,
types.ServiceUpdateOptions{},
)
if err != nil {
return err
}
for _, warning := range response.Warnings {
logrus.Warn(warning)
}
return nil
}

View File

@ -29,22 +29,27 @@ func LoadComposefile(opts Deploy, appEnv map[string]string) (*composetypes.Confi
config, err := loader.Load(configDetails, DontSkipValidation)
if err != nil {
if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok {
return nil, fmt.Errorf("compose file contains unsupported options:\n\n%s",
return nil, fmt.Errorf("compose file contains unsupported options: %s",
propertyWarnings(fpe.Properties))
}
return nil, err
}
recipeName, exists := appEnv["RECIPE"]
if !exists {
recipeName, _ = appEnv["TYPE"]
}
unsupportedProperties := loader.GetUnsupportedProperties(dicts...)
if len(unsupportedProperties) > 0 {
logrus.Warnf("Ignoring unsupported options: %s\n\n",
strings.Join(unsupportedProperties, ", "))
logrus.Warnf("%s: ignoring unsupported options: %s",
recipeName, strings.Join(unsupportedProperties, ", "))
}
deprecatedProperties := loader.GetDeprecatedProperties(dicts...)
if len(deprecatedProperties) > 0 {
logrus.Warnf("Ignoring deprecated options:\n\n%s\n\n",
propertyWarnings(deprecatedProperties))
logrus.Warnf("%s: ignoring deprecated options: %s",
recipeName, propertyWarnings(deprecatedProperties))
}
return config, nil
}

View File

@ -112,7 +112,7 @@ func GetDeployedServicesByName(ctx context.Context, cl *dockerclient.Client, sta
// IsDeployed chekcks whether an appp is deployed or not.
func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) {
version := ""
version := "unknown"
isDeployed := false
filter := filters.NewArgs()
@ -132,12 +132,12 @@ func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string)
}
}
logrus.Debugf("'%s' has been detected as deployed with version '%s'", stackName, version)
logrus.Debugf("%s has been detected as deployed with version %s", stackName, version)
return true, version, nil
}
logrus.Debugf("'%s' has been detected as not deployed", stackName)
logrus.Debugf("%s has been detected as not deployed", stackName)
return isDeployed, version, nil
}
@ -158,7 +158,7 @@ func pruneServices(ctx context.Context, cl *dockerclient.Client, namespace conve
}
// RunDeploy is the swarm implementation of docker stack deploy
func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config) error {
func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error {
ctx := context.Background()
if err := validateResolveImageFlag(&opts); err != nil {
@ -170,7 +170,7 @@ func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config) e
opts.ResolveImage = ResolveImageNever
}
return deployCompose(ctx, cl, opts, cfg)
return deployCompose(ctx, cl, opts, cfg, appName, dontWait)
}
// validateResolveImageFlag validates the opts.resolveImage command line option
@ -183,7 +183,7 @@ func validateResolveImageFlag(opts *Deploy) error {
}
}
func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, config *composetypes.Config) error {
func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error {
namespace := convert.NewNamespace(opts.Namespace)
if opts.Prune {
@ -224,7 +224,7 @@ func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, co
return err
}
return deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage)
return deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage, appName, dontWait)
}
func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} {
@ -339,7 +339,9 @@ func deployServices(
services map[string]swarm.ServiceSpec,
namespace convert.Namespace,
sendAuth bool,
resolveImage string) error {
resolveImage string,
appName string,
dontWait bool) error {
existingServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil {
return err
@ -358,18 +360,6 @@ func deployServices(
encodedAuth string
)
// FIXME: disable for now as not sure how to avoid having a `dockerCli`
// instance here and would rather not copy/pasta that entire module in
// right now for something that we don't even support right now. Will skip
// this for now.
if sendAuth {
// Retrieve encoded auth token from the image reference
// encodedAuth, err = command.RetrieveAuthTokenFromImage(ctx, dockerCli, image)
// if err != nil {
// return err
// }
}
if service, exists := existingServiceMap[name]; exists {
logrus.Infof("Updating service %s (id: %s)\n", name, service.ID)
@ -402,7 +392,6 @@ func deployServices(
// Stack deploy does not have a `--force` option. Preserve existing
// ForceUpdate value so that tasks are not re-deployed if not updated.
// TODO move this to API client?
serviceSpec.TaskTemplate.ForceUpdate = service.Spec.TaskTemplate.ForceUpdate
response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts)
@ -438,14 +427,19 @@ func deployServices(
for _, serviceName := range serviceIDs {
serviceNames = append(serviceNames, serviceName)
}
logrus.Infof("waiting for services to converge: %s", strings.Join(serviceNames, ", "))
if dontWait {
logrus.Warn("skipping converge logic checks")
return nil
}
logrus.Infof("waiting for services to converge: %s", strings.Join(serviceNames, ", "))
ch := make(chan error, len(serviceIDs))
for serviceID, serviceName := range serviceIDs {
logrus.Debugf("waiting on %s to converge", serviceName)
go func(s string) {
ch <- waitOnService(ctx, cl, s)
}(serviceID)
go func(sID, sName, aName string) {
ch <- WaitOnService(ctx, cl, sID, aName)
}(serviceID, serviceName, appName)
}
for _, serviceID := range serviceIDs {
@ -475,7 +469,7 @@ func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespa
// https://github.com/docker/cli/blob/master/cli/command/service/helpers.go
// https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go
func waitOnService(ctx context.Context, cl *dockerclient.Client, serviceID string) error {
func WaitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, appName string) error {
errChan := make(chan error, 1)
pipeReader, pipeWriter := io.Pipe()
@ -485,12 +479,31 @@ func waitOnService(ctx context.Context, cl *dockerclient.Client, serviceID strin
go io.Copy(ioutil.Discard, pipeReader)
timeout := 60 * time.Second
timeout := 50 * time.Second
select {
case err := <-errChan:
return err
case <-time.After(timeout):
return fmt.Errorf("%s has still not converged (%s second timeout)?", serviceID, timeout)
return fmt.Errorf(fmt.Sprintf(`
%s has not converged (%s second timeout reached).
This does not necessarily mean your deployment has failed, it may just be that
the app is taking longer to deploy based on your server resources or network
latency.
You can track latest deployment status with:
abra app ps --watch %s
And inspect the logs with:
abra app logs %s
If a service is failing to even start, try smoke out the error with:
abra app errors --watch %s
`, appName, timeout, appName, appName, appName))
}
}

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