Compare commits

..

197 Commits

Author SHA1 Message Date
a7ebcd8950 chore: bump for new RC 2021-11-18 21:18:40 +01:00
e589709cb0 fix: attempt to include IdentityFile if available
This is part of trying to debug:

    coop-cloud/organising#250

And also part of:

    coop-cloud/docs.coopcloud.tech#27

Where I now try to specify the same logic as `ssh -i <my-key-path>` in
the underlying connection logic. This should help with being more
explicit about what key is being used via the SSH config file.
2021-11-18 21:16:10 +01:00
56c3e070f5 fix: log what keys are loaded with the ssh-agent
Closes coop-cloud/organising#249.
2021-11-18 20:04:57 +01:00
cc37615d83 refactor: move debug to internal 2021-11-18 20:04:40 +01:00
0b37f63248 chore(deps): go mod tidy 2021-11-18 09:49:25 +01:00
9c3a06a7d9 chore(deps): update module github.com/docker/docker to v20.10.11 2021-11-18 09:49:25 +01:00
cdef8b5ea5 chore(deps): update module github.com/docker/cli to v20.10.11 2021-11-18 09:49:25 +01:00
cba261b18c chore(deps): update module github.com/hetznercloud/hcloud-go to v1.33.1 2021-11-18 09:49:25 +01:00
1f6e4fa4a3 fix: ensure to init/commit the new recipe repo
Part of coop-cloud/organising#247.
2021-11-15 18:55:13 +01:00
4a245c3e02 fix: ensure .git repo exists
Part of coop-cloud/organising#247.
2021-11-15 18:55:13 +01:00
299faa1adf refactor: move file pulling/pushing logic to internal 2021-11-15 16:48:23 +01:00
704e773a16 chore(deps): run go mod tidy 2021-11-15 09:20:04 +01:00
7143d09fd4 Merge remote-tracking branch 'origin/renovate/main-github.com-docker-cli-20.x' into main 2021-11-15 09:19:40 +01:00
4e76d49c80 Merge remote-tracking branch 'origin/renovate/main-github.com-docker-docker-20.x' into main 2021-11-15 09:19:30 +01:00
c9dff0c3bd Merge remote-tracking branch 'origin/renovate/main-github.com-gliderlabs-ssh-0.x' into main 2021-11-15 09:19:19 +01:00
e77e72a9e6 Merge remote-tracking branch 'origin/renovate/main-github.com-hetznercloud-hcloud-go-1.x' into main 2021-11-15 09:19:05 +01:00
af6f759c92 chore(deps): update module github.com/moby/sys/signal to v0.6.0 2021-11-15 08:16:57 +00:00
034295332c chore(deps): update module github.com/kevinburke/ssh_config to v1 2021-11-15 08:16:33 +00:00
dac2489e6d chore(deps): update module github.com/hetznercloud/hcloud-go to v1.33.0 2021-11-15 08:01:39 +00:00
7bdc1946a2 chore(deps): update module github.com/gliderlabs/ssh to v0.3.3 2021-11-15 08:01:30 +00:00
2439643895 chore(deps): update module github.com/docker/docker to v20.10.10 2021-11-15 08:01:22 +00:00
0876f677d1 chore(deps): update module github.com/docker/cli to v20.10.10 2021-11-15 08:01:17 +00:00
31dafb3ae4 chore(deps): update module github.com/alecaivazis/survey/v2 to v2.3.2 2021-11-15 08:01:13 +00:00
915083b426 fix: time out on 60 sec + of converge checks
See coop-cloud/organising#246.
2021-11-14 23:15:35 +01:00
486a1717e7 fix: dont attempt to clone is local repo is there
See coop-cloud/organising#247.
2021-11-14 22:54:55 +01:00
9122c0a9b8 fix: ensure domain/server resolve to same ipv4
See coop-cloud/organising#227 (comment).
2021-11-14 22:47:18 +01:00
85ff04202f fix: ensure ipv4 is present for app deploys
See coop-cloud/organising#227.
2021-11-13 23:04:58 +01:00
ecba4e01f1 feat: autocomplete for app cp app names 2021-11-13 22:50:45 +01:00
751b187df6 fix: check local path exists
See coop-cloud/organising#245.
2021-11-13 22:50:45 +01:00
f74261dbe6 docs: document app cp command syntax
See coop-cloud/organising#245.
2021-11-13 22:50:45 +01:00
2600a8137c chore(deps): add renovate.json 2021-11-13 20:26:28 +00:00
b6a6163eff chore: skip new repo + sort [ci skip] 2021-11-13 20:55:50 +01:00
c25b2b17df feat: upgrade to rc from abra 2021-11-13 17:34:20 +01:00
713308e0b8 docs: reinstate install docs on README [ci skip] 2021-11-12 08:57:30 +01:00
fcbf41ee95 chore: use alpha format 2021-11-12 08:25:38 +01:00
5add4ccc1b refactor(installer): remove doubled code for RC 2021-11-11 17:40:14 +01:00
9220a8c09b feat(installer): download rc with --rc 2021-11-11 17:10:48 +01:00
f78a04109c fix: clarify when deploy done [ci skip] 2021-11-10 09:15:52 +01:00
b67ad02f87 feat: rudimentary deploy status checking
See coop-cloud/organising#209.
2021-11-10 09:06:55 +01:00
215431696e feat: implement app restart
Closes coop-cloud/organising#239.
2021-11-10 07:52:45 +01:00
cd361237e7 Revert "Revert "test: remove broken tests for client""
This reverts commit 59031595ea.

Argh, reverted this by accident, heres another one!
2021-11-09 18:25:28 +01:00
db10c7b849 feat: run wizard mode on recipe upgrade [ci skip] 2021-11-09 18:06:06 +01:00
d38f82ebe7 docs: drop recipe [ci skip] 2021-11-09 18:05:53 +01:00
59031595ea Revert "test: remove broken tests for client"
This reverts commit 17a5f1529a.
2021-11-09 17:58:31 +01:00
6f26b51f3e fix: only check host keys on requested hosts
See coop-cloud/organising#242.
2021-11-09 17:44:13 +01:00
17a5f1529a test: remove broken tests for client 2021-11-09 13:03:33 +01:00
2ba6445daa test: go verbose on testing [ci skip] 2021-11-09 11:36:24 +01:00
edb427a7ae feat: implement host key checking
Closes coop-cloud/organising#237.
2021-11-08 15:37:23 +01:00
3dc186e231 chore: make comment more general [ci skip] 2021-11-07 00:13:03 +01:00
1467ae5007 feat: teach catalogue generate to use git 2021-11-07 00:03:01 +01:00
2b9395be1a feat: make sync use wizard mode
Some bugs squashed while testing this extensively.
2021-11-06 23:40:22 +01:00
a539033b55 docs: use consistent naming [ci skip] 2021-11-06 22:38:29 +01:00
63d9703d9d feat: make release use wizard mode
Some bugs squashed while testing this extensively.
2021-11-06 22:36:01 +01:00
f9726b6643 WIP: temporarily avoid SSH host key checking
Closes coop-cloud/organising#234.
Closes coop-cloud/organising#142.
2021-11-05 12:33:32 +01:00
4a0761926c chore: avoid reverts in the change logi [ci skip] 2021-11-03 10:13:45 +01:00
de7054fd74 fix: use x-platform code for pdeathsig
This might cause the macosx build not to fail, I hope.

See https://github.com/docker/cli/tree/v20.10.10/cli/connhelper/commandconn
2021-11-03 09:57:35 +01:00
0e0e2db755 chore: publish new version 2021-11-03 09:44:11 +01:00
04e24022f5 feat: auto-deploy traefik prototype
Closes coop-cloud/organising#212.
2021-11-03 09:41:20 +01:00
c227972c12 WIP: make "abra app deploy" callable by code
Closes coop-cloud/organising#212.
2021-11-03 09:21:15 +01:00
911f22233f refactor: use better name for file 2021-11-03 09:11:30 +01:00
7d8e2d9dd1 WIP: make "abra app new" callable by code
Part of coop-cloud/organising#212.
2021-11-03 09:10:13 +01:00
f041083604 feat: support hetzner cloud server removal
Part of coop-cloud/organising#212.
2021-11-03 08:34:36 +01:00
f57ae1e904 fix: remove debug statements
Closes coop-cloud/organising#217.
2021-11-03 07:56:26 +01:00
49a87cae2e fix: use more robust output cmd 2021-11-03 07:56:19 +01:00
f0de18a7f0 fix: use echo style + fix formatting 2021-11-03 07:48:30 +01:00
1caef09cd2 feat: autocomplete helper command
Closes coop-cloud/organising#216.
2021-11-03 07:28:18 +01:00
e4e606efb0 feat: catalogue generate now rate limits
Closes coop-cloud/organising#231.
2021-11-03 06:53:38 +01:00
08aca28d9d chore: upgrade tagcmp + run mod tidy 2021-11-03 06:29:06 +01:00
f02ea7ca0d feat: add recipe version pinning
closes: coop-cloud/organising#186
2021-11-03 05:28:23 +00:00
3d3c4b3aae fix: add new repo to skip list 2021-11-02 21:52:11 +01:00
e37b49201f fix: use IdleConnTimeout/ConnectTimeout
This is an attempt to set sensible timeouts on abra connections. This
might not be the last word on this but it seems that SSH connections now
bail out correctly and other kinds of commands don't explode (e.g.
logs).

Closes coop-cloud/organising#222.
Closes coop-cloud/organising#218.
2021-11-02 15:49:11 +01:00
ede5a59562 Revert c76601c9ce
This is already handled and does not need to be run again.
2021-11-02 15:47:09 +01:00
fc2deda1f6 Revert "fix: drop copy/pasta, keep timeouts"
This reverts commit a170e26e27.

Attempting to add more nuanced timeout logic.
2021-11-02 15:18:17 +01:00
c76601c9ce fix: ensure version for regular deploy 2021-11-02 15:16:19 +01:00
7f176d8e2f fix: ensure logging for status checks
Closes coop-cloud/organising#226.
2021-11-02 15:15:52 +01:00
9b704b002b fix: include app arg in docs
Follow up to bd92c52eed.
2021-11-02 14:54:53 +01:00
ab02c5f0dd feat: support better domain defaults
Closes coop-cloud/organising#221.
2021-11-02 14:44:16 +01:00
f2b02e39a7 fix: allow config to open broken env files
Closes coop-cloud/organising#223.
2021-11-02 14:38:53 +01:00
31f6bd06a5 fix: use correct formatting function 2021-11-02 14:24:40 +01:00
bd92c52eed fix: document secret names more coherently
Closes coop-cloud/organising#215.
2021-11-02 14:21:55 +01:00
0486091768 fix: handle flags order validatio better
Closes coop-cloud/organising#214.
2021-11-02 14:08:54 +01:00
3b77607f36 fix: better error messages for missing repos 2021-11-02 13:36:40 +01:00
f833ccb864 fix: handle recipe name passing correctly
Closes coop-cloud/organising#224.
2021-11-02 13:33:46 +01:00
7022f42711 fix: docs and fix for new recipes
Closes coop-cloud/organising#228.
2021-11-02 13:29:58 +01:00
c76bd25c1d Revert "chore: tweak libdns/gandi go.sum entry >.<"
This reverts commit a6b5ac3410.

Mystery checksum ping/pong issue goes on.
2021-11-02 13:23:15 +01:00
3wc
a6b5ac3410 chore: tweak libdns/gandi go.sum entry >.< 2021-11-02 14:17:26 +02:00
71225d2099 feat(installer): add hashsum checking 2021-10-26 12:29:53 +02:00
5d59d12d75 refactor(installer): use more precise sed command 2021-10-26 11:54:10 +02:00
d56400eea8 fix: bail out on unstage changes for plain --force 2021-10-26 10:52:26 +02:00
b3496ad286 fix: log correctly on provisioning 2021-10-26 01:30:23 +02:00
066b2b9373 fix: stream output from remote ssh commands 2021-10-26 01:30:10 +02:00
aec11bda28 fix: add ssh conn time outs 2021-10-26 00:33:18 +02:00
9a513a0700 fix: --local/--provision works 2021-10-26 00:27:45 +02:00
9f3ab0de9e refactor: drop VPS 2021-10-26 00:27:32 +02:00
e26afb97af fix: support empty ssh keys 2021-10-26 00:27:22 +02:00
960e47437c fix: show defaults, dont set 2021-10-26 00:25:14 +02:00
8e3f90a7f3 fix: server inputs handling + better logging 2021-10-25 23:48:49 +02:00
1d7cb0d9b6 fix: ensure client connections work 2021-10-25 23:48:19 +02:00
4d2a2d42fb fix: ensure provider is set 2021-10-25 20:01:20 +02:00
bdae61ed51 docs: taking a pass on sub cmd docs 2021-10-25 19:58:50 +02:00
766e3008f6 fix: remove duplicate check [ci skip] 2021-10-25 19:51:55 +02:00
383f857f4a feat(installer): check if ~/local/.bin is in $PATH 2021-10-25 18:14:10 +02:00
3d46ce6db2 refactor: more seamless SSH connections 2021-10-25 11:13:41 +02:00
9e0d77d5c6 refactor: better SSH connection details handling 2021-10-25 10:42:39 +02:00
f9e2d24550 docs: clarify when this can be connected to 2021-10-25 10:09:55 +02:00
8772217f41 fix: working provisioning post chaos testing 2021-10-25 10:06:16 +02:00
a7970132c2 fix: server/record improved output + interactivity 2021-10-25 09:02:24 +02:00
2d091a6b00 refactor: name to match logic 2021-10-25 09:02:13 +02:00
147687d7ce fix: handle inputs for server new correctly 2021-10-25 08:23:29 +02:00
9a0e12258a feat: provision docker installation 2021-10-24 23:15:38 +02:00
1396f15c78 chore: new loc count by author 2021-10-24 18:08:00 +02:00
2e2560dea7 docs: fix typos [ci skip] 2021-10-22 13:37:31 +02:00
c789a70653 docs: add additional op [ci skip] 2021-10-22 13:36:30 +02:00
8f55330210 docs: further server docs [ci skip] 2021-10-22 13:35:53 +02:00
d54a45bef7 docs: try to clarify that further [ci skip] 2021-10-22 13:31:14 +02:00
fdc0246f1d feat: server rm more functional 2021-10-22 12:01:17 +02:00
a394618965 chore: those can break as well, include 2021-10-22 11:43:41 +02:00
8cd9f2700f refactor!: server add provisions/deploys traefik 2021-10-22 11:43:07 +02:00
b72fa28ddb feat: server list expands connection string 2021-10-22 10:41:19 +02:00
313e3beb1e refactor!: abra server interface more coherent
This follows our app new UX and interactive mode design.
2021-10-22 10:31:33 +02:00
94c7f59113 fix: dont use e.g. if already has default 2021-10-22 09:23:28 +02:00
5ae06bbd42 refactor!: abra domain -> abra record + prompts
This reconciles the fact that we manage records and not domains which
was a bad first naming take on this imho. Now it is clear that we are
manipulating domain name records and not entire zones.

The UX of record creation/deletion now mirrors the UX of new apps. All
the things are prompted for.
2021-10-22 08:58:18 +02:00
9f9248b987 feat: select prompt for recipes on app new 2021-10-22 08:21:46 +02:00
2bb4a9c063 docs: fix flag name [ci skip] 2021-10-21 20:58:01 +02:00
0c8dba0681 docs: try handles directly [ci skip] 2021-10-21 20:53:04 +02:00
a491332c1c feat: support no-input mode for deploy ops 2021-10-21 20:48:45 +02:00
6a75ffc051 docs: shape up release docs [ci skip] 2021-10-21 20:37:04 +02:00
5261d1a033 chore: drop unused dep [ci skip] 2021-10-21 20:17:48 +02:00
a458a5d9f7 docs: mark upstreams for all upstreams 2021-10-21 19:54:43 +02:00
5ce2419354 docs: mark new pkg for upstream [ci skip] 2021-10-21 19:41:20 +02:00
963f8dcc73 fix: recover tests from overzealous cleanup 2021-10-21 19:40:26 +02:00
dc04cf5ff7 chore: migrate all upstream code to own dir 2021-10-21 19:35:13 +02:00
80921c9f55 fix: remove cruft + readme pass + document forks 2021-10-21 18:35:24 +02:00
8b15f2de5b chore: publish new release 2021-10-21 16:03:19 +02:00
cdb76e7276 fix: catch multiple containers correctly 2021-10-21 16:01:54 +02:00
a170e26e27 fix: drop copy/pasta, keep timeouts 2021-10-21 15:42:50 +02:00
03b1882b81 chore: publish new tag 2021-10-21 15:17:34 +02:00
2fcdaca75f fix: dont duplicate info output 2021-10-21 15:13:24 +02:00
c5f44cf340 feat: show undploy overview 2021-10-21 15:10:43 +02:00
7a5ad65178 fix: load timeout before other opts 2021-10-21 15:06:03 +02:00
6d4ee3de0d fix: force flag works for upgrade 2021-10-21 11:44:47 +02:00
63318fb6ff fix: handle chaos mode correctly for deploy
Closes coop-cloud/organising#210.
2021-10-21 10:19:30 +02:00
07ffa08a07 chore: remove unused files 2021-10-20 21:04:09 +02:00
0e5e7490b3 docs: some rewording and clarifying 2021-10-20 17:52:54 +02:00
640032b8fe fix: remove duplicate version command
We can use --version/-v instead.
2021-10-20 17:48:50 +02:00
39babea963 docs: remove that missing feature [ci skip] 2021-10-20 17:36:41 +02:00
07613f5163 fix: devendor capsul code
Closes coop-cloud/organising#155.
2021-10-20 17:34:01 +02:00
7f1d9eeaec fix: check if record already exists 2021-10-20 16:56:34 +02:00
02d24104e1 feat: domain CRUD complete with Gandi provider 2021-10-20 16:52:19 +02:00
da8d72620a test: warning not to test cli [ci skip] 2021-10-20 10:15:55 +01:00
96ccadc70f refactor: move making app struct to construct func
makes the code cleaner and easier to grab the app struct for testing
2021-10-20 09:45:38 +01:00
8703370785 WIP: domain create 2021-10-20 00:05:57 +02:00
7d8c53299d docs: more domain command docs hacking 2021-10-20 00:05:49 +02:00
0110aceb1f docs: rewording 2021-10-19 23:03:12 +02:00
aec1e4520d fix: handle missing containers
Closes coop-cloud/organising#198.
2021-10-19 22:50:43 +02:00
74bcb99c70 fix: use this weird default
Closes coop-cloud/organising#207.
2021-10-19 22:43:43 +02:00
dd4f2b48ec fix: explode when wrong provider chosen 2021-10-19 10:19:31 +02:00
7f3f41ede4 docs: dns list docs 2021-10-18 22:20:11 +02:00
597b4b586e WIP: domain listing with Gandi
Rethinking the interface already.
2021-10-18 22:16:29 +02:00
7ea3df45d4 WIP: dns support via libdns 2021-10-18 20:35:43 +02:00
5941ed9728 fix: handle exceptions 2021-10-18 20:35:32 +02:00
d1e42752e2 fix: set connection timeouts + clean up bad contexts
Closes coop-cloud/organising#205.
2021-10-18 10:48:43 +02:00
9dfbd21c61 fix: parse args correctly for validation 2021-10-18 09:43:32 +02:00
9526d1fde6 fix: ensure we have version checked out on deploy 2021-10-18 09:30:43 +02:00
62cc7ef92d feat: upgrade/downgrade support chaos mode 2021-10-18 08:57:25 +02:00
c5a7a831d2 docs: chaos mode flag docs 2021-10-18 08:35:59 +02:00
4aae186f5f chore: squash formatting issue 2021-10-18 08:27:39 +02:00
2f9b11f389 feat: support deploying with chaos mode 2021-10-18 08:14:06 +02:00
6d42e72f16 fix: allow for client creation on default context
See coop-cloud/organising#206.
2021-10-17 23:50:44 +02:00
5be190e110 fix: check that docker is installed on local add 2021-10-17 23:50:28 +02:00
c1390f232e fix: show "local" instead of "default" 2021-10-17 23:50:12 +02:00
3wc
95e19f03c4 fix: make release not crash on missing images 2021-10-16 18:57:21 +02:00
dc040a0b38 chore: change test context names 2021-10-16 13:26:03 +02:00
e6e2e5214f test: add tests for pkg/client/client.go 2021-10-16 13:04:57 +02:00
61452b5f32 docs: add README.md to document testing 2021-10-16 12:26:43 +02:00
78460ac0ba test: increatse client/context.go coverage to 90% 2021-10-16 11:41:41 +02:00
0615c3f745 fix: support downgrade/upgrade for unknown versions 2021-10-15 09:58:45 +02:00
3wc
e820e0219d docs: how to enable bash autocomplete from source 2021-10-14 22:37:32 +02:00
75fb9a2774 chore: publish new version 2021-10-14 13:31:18 +02:00
0d500b636d feat: more info on version changing deployments 2021-10-14 13:30:33 +02:00
5dd97cace0 docs: expand deploy/upgrade/downgrade docs 2021-10-14 12:26:07 +02:00
ae32b1eed2 fix: standardise checkout options 2021-10-14 12:17:58 +02:00
113bdf9e86 feat: add stats to app list 2021-10-14 12:02:12 +02:00
d4d4da19b7 feat: first steps towards watchable ps output
See coop-cloud/organising#178.
2021-10-14 11:51:40 +02:00
454ee696d6 fix: make ps a bit more useful and less verbose 2021-10-14 11:36:03 +02:00
ca16c002ba docs: add more description for versions command 2021-10-14 11:32:32 +02:00
91cc8b00b3 fix: avoid alias conflict 2021-10-14 11:32:25 +02:00
d0828c4d8d fix: teach app version command to read new versions 2021-10-14 11:29:57 +02:00
b69aed3bcf feat: add rollback command
Closes coop-cloud/organising#127.
2021-10-14 01:52:55 +02:00
875255fd8c feat: add upgrade command 2021-10-14 01:23:04 +02:00
2dca602c0b fix: error handling in deploy 2021-10-14 01:22:54 +02:00
1dca8a1067 chore: set 1.16 as requirement now
Closes coop-cloud/organising#201.
2021-10-13 16:55:58 +02:00
37022bf0c8 feat: make deploy only deploy
See coop-cloud/organising#127.
2021-10-13 16:51:04 +02:00
eb5b35d47f build: change sed flags in installer for mac os compatibility 2021-10-13 16:36:07 +02:00
ece1130797 build: add automatic os and architecture detection to installer script 2021-10-13 15:51:19 +02:00
c266316f7e build: remove python3 dependency from installer 2021-10-13 15:08:00 +02:00
d804276cf2 feat: add pre-deploy overview 2021-10-12 13:25:23 +02:00
97 changed files with 5540 additions and 1896 deletions

View File

@ -1,40 +0,0 @@
{{ range .Versions }}
<a name="{{ .Tag.Name }}"></a>
## {{ if .Tag.Previous }}[{{ .Tag.Name }}]({{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}){{ else }}{{ .Tag.Name }}{{ end }}
> {{ datetime "2006-01-02" .Tag.Date }}
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
* {{ .Subject }}
{{ end }}
{{ end -}}
{{- if .RevertCommits -}}
### Reverts
{{ range .RevertCommits -}}
* {{ .Revert.Header }}
{{ end }}
{{ end -}}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
* {{ .Header }}
{{ end }}
{{ end -}}
{{- if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}

View File

@ -1,27 +0,0 @@
style: github
template: CHANGELOG.tpl.md
info:
title: CHANGELOG
repository_url: https://git.autonomic.zone:2222/coop-cloud/go-abra
options:
commits:
# filters:
# Type:
# - feat
# - fix
# - perf
# - refactor
commit_groups:
# title_maps:
# feat: Features
# fix: Bug Fixes
# perf: Performance Improvements
# refactor: Code Refactoring
header:
pattern: "^(\\w*)\\:\\s(.*)$"
pattern_maps:
- Type
- Subject
notes:
keywords:
- BREAKING CHANGE

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ abra
vendor/
.envrc
dist/
*fmtcoverage.html

View File

@ -32,9 +32,7 @@ changelog:
filters:
exclude:
- "^WIP:"
- "^chore:"
- "^docs:"
- "^refactor:"
- "^style:"
- "^test:"
- "^tests:"
- "^Revert"

View File

@ -32,7 +32,14 @@ static:
@staticcheck $(ABRA)
test:
@go test ./... -cover
@go test ./... -cover -v
loc:
@find . -name "*.go" | xargs wc -l
loc-author:
@git ls-files -z | \
xargs -0rn 1 -P "$$(nproc)" -I{} sh -c 'git blame -w -M -C -C --line-porcelain -- {} | grep -I --line-buffered "^author "' | \
sort -f | \
uniq -ic | \
sort -n

View File

@ -7,92 +7,59 @@
The Co-op Cloud utility belt 🎩🐇
`abra` is a command-line tool for managing your own [Co-op Cloud](https://coopcloud.tech). It can provision new servers, create applications, deploy them, run backup and restore operations and a whole lot of other things. It is the go-to tool for day-to-day operations when managing a Co-op Cloud instance.
`abra` is 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.
## Install
## Quick install
### Arch-based Linux Distros
[abra (coming-soon)](https://aur.archlinux.org/packages/abra/) or for the latest version on git [abra-git](https://aur.archlinux.org/packages/abra-git/)
```sh
yay -S abra-git # or abra
```bash
curl https://install.abra.autonomic.zone | bash
```
### Debian-based Linux Distros
Or using the latest release candidate (extra experimental!):
**Coming Soon**
### Homebrew
**Coming Soon**
### Build from source
```sh
git clone https://git.coopcloud.tech/coop-cloud/abra
cd abra
go env -w GOPRIVATE=coopcloud.tech
make install
```bash
curl https://install.abra.autonomic.zone | bash -s -- --rc
```
The abra binary will be in `$GOPATH/bin`.
## Autocompletion
**bash**
Copy `scripts/autocomplete/bash` into `/etc/bash_completion.d/` and rename
it to abra.
```
sudo cp scripts/autocomplete/bash /etc/bash_completion.d/abra
source /etc/bash_completion.d/abra
```
**(fi)zsh**
(fi)zsh doesn't have an autocompletion folder by default but you can create one, then copy `scripts/autocomplete/zsh` into it and add a couple lines to your `~/.zshrc` or `~/.fizsh/.fizshrc`
```
sudo mkdir /etc/zsh/completion.d/
sudo cp scripts/autocomplete/zsh /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
```
(replace .zshrc with ~/.fizsh/.fizshrc if you use fizsh)
Source for this script is in [scripts/installer/installer](./scripts/installer/installer).
## Hacking
Install direnv, run `cp .envrc.sample .envrc`, then run `direnv allow` in this directory. This will set coopcloud repos as private due to [this bug.](https://git.coopcloud.tech/coop-cloud/coopcloud.tech/issues/20#issuecomment-8201). Or you can run `go env -w GOPRIVATE=coopcloud.tech` but I'm not sure how persistent this is.
### 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
### 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
### Making a new release
- Change `ABRA_VERSION` to match the new tag in [`scripts`](./scripts/installer/installer) (use [semver](https://semver.org))
- Commit that change (e.g. `git commit -m 'chore: publish next tag 0.3.1-alpha'`)
- Make a new tag (e.g. `git tag 0.y.z-alpha`)
- 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 version`)
- Check the release worked, (e.g. `abra upgrade; abra -v`)
## Fork maintenance
### Fork maintenance
#### `godotenv`
We maintain a fork of [godotenv](https://github.com/Autonomic-Cooperative/godotenv) for two features:
@ -102,3 +69,7 @@ We maintain a fork of [godotenv](https://github.com/Autonomic-Cooperative/godote
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.

View File

@ -7,7 +7,7 @@ import (
// AppCommand defines the `abra app` command and ets subcommands
var AppCommand = &cli.Command{
Name: "app",
Usage: "Manage apps",
Usage: "Manage deployed apps",
Aliases: []string{"a"},
ArgsUsage: "<app>",
Description: `
@ -18,7 +18,9 @@ to scaling apps up and spinning them down.
Subcommands: []*cli.Command{
appNewCommand,
appConfigCommand,
appRestartCommand,
appDeployCommand,
appUpgradeCommand,
appUndeployCommand,
appBackupCommand,
appRestoreCommand,

View File

@ -1,6 +1,7 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
@ -17,7 +18,21 @@ var appConfigCommand = &cli.Command{
Aliases: []string{"c"},
Usage: "Edit app config",
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
appName := c.Args().First()
if appName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no app provided"))
}
files, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
appFile, exists := files[appName]
if !exists {
logrus.Fatalf("cannot find app with name '%s'", appName)
}
ed, ok := os.LookupEnv("EDITOR")
if !ok {
@ -30,7 +45,7 @@ var appConfigCommand = &cli.Command{
}
}
cmd := exec.Command(ed, app.Path)
cmd := exec.Command(ed, appFile.Path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

View File

@ -5,13 +5,8 @@ import (
"os"
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"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"
)
@ -21,6 +16,18 @@ var appCpCommand = &cli.Command{
Aliases: []string{"c"},
ArgsUsage: "<src> <dst>",
Usage: "Copy files to/from a running app service",
Description: `
This command supports copying files to and from any app service file system.
If you want to copy a myfile.txt to the root of the app service:
abra app cp <app> myfile.txt app:/
And if you want to copy that file back to your current working directory locally:
abra app cp <app> app:/myfile.txt .
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
@ -64,61 +71,28 @@ var appCpCommand = &cli.Command{
logrus.Debugf("assuming transfer is going TO the container")
}
appFiles, err := config.LoadAppFiles("")
if !isToContainer {
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
logrus.Fatalf("%s does not exist locally?", dstPath)
}
}
err := internal.ConfigureAndCp(c, app, srcPath, dstPath, service, isToContainer)
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
},
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

@ -4,8 +4,6 @@ import (
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
stack "coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/config"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -15,44 +13,23 @@ var appDeployCommand = &cli.Command{
Name: "deploy",
Aliases: []string{"d"},
Usage: "Deploy an app",
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: app.StackName(),
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err)
}
return nil
Flags: []cli.Flag{
internal.ForceFlag,
internal.ChaosFlag,
},
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.
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.
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
Action: internal.DeployAction,
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {

View File

@ -1,12 +1,14 @@
package app
import (
"fmt"
"sort"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/ssh"
"coopcloud.tech/tagcmp"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -68,6 +70,12 @@ can take some time.
}
sort.Sort(config.ByServerAndType(apps))
for _, app := range apps {
if err := ssh.EnsureHostKey(app.Server); err != nil {
logrus.Fatal(err)
}
}
statuses := make(map[string]map[string]string)
tableCol := []string{"Server", "Type", "Domain"}
if status {
@ -81,6 +89,13 @@ can take some time.
table := abraFormatter.CreateTable(tableCol)
table.SetAutoMergeCellsByColumnIndex([]int{0})
var (
versionedAppsCount int
unversionedAppsCount int
onLatestCount int
canUpgradeCount int
)
for _, app := range apps {
var tableRow []string
if app.Type == appType || appType == "" {
@ -98,8 +113,10 @@ can take some time.
status = statusMeta["status"]
}
tableRow = append(tableRow, status, version)
versionedAppsCount++
} else {
tableRow = append(tableRow, status, version)
unversionedAppsCount++
}
var newUpdates []string
@ -131,6 +148,7 @@ can take some time.
tableRow = append(tableRow, "unknown")
} else {
tableRow = append(tableRow, "on latest")
onLatestCount++
}
} else {
// FIXME: jeezus golang why do you not have a list reverse function
@ -138,12 +156,23 @@ can take some time.
newUpdates[i], newUpdates[j] = newUpdates[j], newUpdates[i]
}
tableRow = append(tableRow, strings.Join(newUpdates, "\n"))
canUpgradeCount++
}
}
}
table.Append(tableRow)
}
stats := fmt.Sprintf(
"Total apps: %v | Versioned: %v | Unversioned: %v | On latest: %v | Can upgrade: %v",
len(apps),
versionedAppsCount,
unversionedAppsCount,
onLatestCount,
canUpgradeCount,
)
table.SetCaption(true, stats)
table.Render()
return nil

View File

@ -2,47 +2,13 @@ package app
import (
"fmt"
"path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/secret"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
type secrets map[string]string
var domain string
var domainFlag = &cli.StringFlag{
Name: "domain",
Aliases: []string{"d"},
Value: "",
Usage: "Choose a domain name",
Destination: &domain,
}
var newAppServer string
var newAppServerFlag = &cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &newAppServer,
}
var newAppName string
var newAppNameFlag = &cli.StringFlag{
Name: "app-name",
Aliases: []string{"a"},
Value: "",
Usage: "Choose an app name",
Destination: &newAppName,
}
var appNewDescription = `
This command takes a recipe and uses it to create a new app. This new app
configuration is stored in your ~/.abra directory under the appropriate server.
@ -69,14 +35,14 @@ var appNewCommand = &cli.Command{
Aliases: []string{"n"},
Description: appNewDescription,
Flags: []cli.Flag{
newAppServerFlag,
domainFlag,
newAppNameFlag,
internal.NewAppServerFlag,
internal.DomainFlag,
internal.NewAppNameFlag,
internal.PassFlag,
internal.SecretsFlag,
},
ArgsUsage: "<recipe>",
Action: action,
Action: internal.NewAction,
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
@ -90,141 +56,3 @@ var appNewCommand = &cli.Command{
}
},
}
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
func ensureDomainFlag() error {
if domain == "" {
prompt := &survey.Input{
Message: "Specify app domain",
}
if err := survey.AskOne(prompt, &domain); err != nil {
return err
}
}
return nil
}
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
func ensureServerFlag() error {
servers, err := config.GetServers()
if err != nil {
return err
}
if newAppServer == "" {
prompt := &survey.Select{
Message: "Select app server:",
Options: servers,
}
if err := survey.AskOne(prompt, &newAppServer); err != nil {
return err
}
}
return nil
}
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
func ensureAppNameFlag() error {
if newAppName == "" {
prompt := &survey.Input{
Message: "Specify app name:",
Default: config.SanitiseAppName(domain),
}
if err := survey.AskOne(prompt, &newAppName); err != nil {
return err
}
}
return nil
}
// createSecrets creates all secrets for a new app.
func createSecrets(sanitisedAppName string) (secrets, error) {
appEnvPath := path.Join(config.ABRA_DIR, "servers", newAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
appEnv, err := config.ReadEnv(appEnvPath)
if err != nil {
return nil, err
}
secretEnvVars := secret.ReadSecretEnvVars(appEnv)
secrets, err := secret.GenerateSecrets(secretEnvVars, sanitisedAppName, newAppServer)
if err != nil {
return nil, err
}
if internal.Pass {
for secretName := range secrets {
secretValue := secrets[secretName]
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, newAppServer); err != nil {
return nil, err
}
}
}
return secrets, nil
}
// action is the main command-line action for this package
func action(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
if err := config.EnsureAbraDirExists(); err != nil {
logrus.Fatal(err)
}
if err := ensureServerFlag(); err != nil {
logrus.Fatal(err)
}
if err := ensureDomainFlag(); err != nil {
logrus.Fatal(err)
}
if err := ensureAppNameFlag(); err != nil {
logrus.Fatal(err)
}
sanitisedAppName := config.SanitiseAppName(newAppName)
if len(sanitisedAppName) > 45 {
logrus.Fatalf("'%s' cannot be longer than 45 characters", sanitisedAppName)
}
logrus.Debugf("'%s' sanitised as '%s' for new app", newAppName, sanitisedAppName)
if err := config.TemplateAppEnvSample(recipe.Name, newAppName, newAppServer, domain, recipe.Name); err != nil {
logrus.Fatal(err)
}
if internal.Secrets {
secrets, err := createSecrets(sanitisedAppName)
if err != nil {
logrus.Fatal(err)
}
secretCols := []string{"Name", "Value"}
secretTable := abraFormatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
}
if len(secrets) > 0 {
defer secretTable.Render()
}
}
tableCol := []string{"Name", "Domain", "Type", "Server"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, domain, recipe.Name, newAppServer})
fmt.Println("")
fmt.Println(fmt.Sprintf("New '%s' created! Here is your new app overview:", recipe.Name))
fmt.Println("")
table.Render()
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", sanitisedAppName))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", sanitisedAppName))
fmt.Println("")
return nil
}

View File

@ -3,6 +3,7 @@ package app
import (
"fmt"
"strings"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
@ -15,44 +16,33 @@ import (
"github.com/urfave/cli/v2"
)
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{
Name: "ps",
Usage: "Check app status",
Aliases: []string{"p"},
Flags: []cli.Flag{
watchFlag,
},
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
if !watch {
showPSOutput(c)
return nil
}
filters := filters.NewArgs()
filters.Add("name", app.StackName())
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
// TODO: how do we make this update in-place in an x-platform way?
for {
showPSOutput(c)
time.Sleep(2 * time.Second)
}
tableCol := []string{"id", "image", "command", "created", "status", "ports", "names"}
table := abraFormatter.CreateTable(tableCol)
for _, container := range containers {
tableRow := []string{
abraFormatter.ShortenID(container.ID),
abraFormatter.RemoveSha(container.Image),
abraFormatter.Truncate(container.Command),
abraFormatter.HumanDuration(container.Created),
container.Status,
formatter.DisplayablePorts(container.Ports),
strings.Join(container.Names, ", "),
}
table.Append(tableRow)
}
table.Render()
return nil
},
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
@ -67,3 +57,43 @@ var appPsCommand = &cli.Command{
}
},
}
// showPSOutput renders ps output.
func showPSOutput(c *cli.Context) {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs()
filters.Add("name", app.StackName())
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"image", "created", "status", "ports", "names"}
table := abraFormatter.CreateTable(tableCol)
for _, container := range containers {
var containerNames []string
for _, containerName := range container.Names {
trimmed := strings.TrimPrefix(containerName, "/")
containerNames = append(containerNames, trimmed)
}
tableRow := []string{
abraFormatter.RemoveSha(container.Image),
abraFormatter.HumanDuration(container.Created),
container.Status,
formatter.DisplayablePorts(container.Ports),
strings.Join(containerNames, "\n"),
}
table.Append(tableRow)
}
table.Render()
}

72
cli/app/restart.go Normal file
View File

@ -0,0 +1,72 @@
package app
import (
"errors"
"fmt"
"time"
"coopcloud.tech/abra/cli/internal"
"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/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var appRestartCommand = &cli.Command{
Name: "restart",
Usage: "Restart an app",
Aliases: []string{"R"},
ArgsUsage: "<service>",
Description: `This command restarts a service within a deployed app.`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
serviceName := c.Args().Get(1)
if serviceName == "" {
err := errors.New("missing service?")
internal.ShowSubcommandHelpAndError(c, err)
}
cl, err := client.New(app.Server)
if err != nil {
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))
}
logrus.Debugf("attempting to restart %s", serviceFilter)
timeout := 30 * time.Second
if err := cl.ContainerRestart(c.Context, containers[0].ID, &timeout); err != nil {
logrus.Fatal(err)
}
logrus.Infof("%s service restarted", serviceFilter)
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

@ -3,14 +3,15 @@ package app
import (
"fmt"
"context"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -18,8 +19,25 @@ import (
var appRollbackCommand = &cli.Command{
Name: "rollback",
Usage: "Roll an app back to a previous version",
Aliases: []string{"r"},
ArgsUsage: "[<version>]",
Aliases: []string{"r", "downgrade"},
ArgsUsage: "<app>",
Flags: []cli.Flag{
internal.ForceFlag,
internal.ChaosFlag,
},
Description: `
This command rolls an app back to a previous version if one exists.
You may pass "--force/-f" to downgrade to the same version again. This can be
useful if the container runtime has gotten into a weird state.
This action could be destructive, please ensure you have a copy of your app
data beforehand - see "abra app backup <app>" for more.
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
@ -34,48 +52,125 @@ var appRollbackCommand = &cli.Command{
},
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
stackName := app.StackName()
ctx := context.Background()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if len(recipeMeta.Versions) == 0 {
logrus.Fatalf("no catalogue versions available for '%s'", app.Type)
}
deployedVersions, isDeployed, err := appPkg.DeployedVersions(ctx, cl, app)
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("'%s' is not deployed?", app.Name)
}
if _, exists := deployedVersions["app"]; !exists {
logrus.Fatalf("no versioned 'app' service for '%s', cannot determine version", app.Name)
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil {
logrus.Fatal(err)
}
version := c.Args().Get(1)
if version == "" {
// TODO:
// using deployedVersions["app"], get index+1 version from catalogue
// otherwise bail out saying there is nothing to rollback to
} else {
// TODO
// ensure this version is listed in the catalogue
// ensure this version is "older" (lower down in the list)
var availableDowngrades []string
if deployedVersion == "" {
deployedVersion = "unknown"
availableDowngrades = versions
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
}
// TODO
// display table of existing state and expected state and prompt
// run the deployment with this target version!
if deployedVersion != "unknown" && !internal.Chaos {
for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
}
if parsedVersion != parsedDeployedVersion && parsedVersion.IsLessThan(parsedDeployedVersion) {
availableDowngrades = append(availableDowngrades, version)
}
}
logrus.Fatal("command not implemented yet, coming soon TM")
if len(availableDowngrades) == 0 {
logrus.Fatal("no available downgrades, you're on latest")
}
}
// 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]
}
var chosenDowngrade string
if !internal.Chaos {
if internal.Force {
chosenDowngrade = availableDowngrades[0]
logrus.Debugf("choosing '%s' as version to downgrade to (--force)", chosenDowngrade)
} else {
prompt := &survey.Select{
Message: fmt.Sprintf("Please select a downgrade (current version: '%s'):", deployedVersion),
Options: availableDowngrades,
}
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
return err
}
}
}
if !internal.Chaos {
if err := recipe.EnsureVersion(app.Type, chosenDowngrade); err != nil {
logrus.Fatal(err)
}
}
if internal.Chaos {
logrus.Warn("chaos mode engaged")
var err error
chosenDowngrade, err = recipe.ChaosVersion(app.Type)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
if !internal.Force {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
logrus.Fatal(err)
}
}
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err)
}
return nil
},

View File

@ -6,8 +6,8 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/client/container"
"coopcloud.tech/abra/pkg/config"
"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"
@ -55,15 +55,20 @@ var appRunCommand = &cli.Command{
}
serviceName := c.Args().Get(1)
stackAndServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName))
filters.Add("name", stackAndServiceName)
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
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 but got %d", len(containers))
logrus.Fatalf("expected 1 container matching '%s' but got %d", stackAndServiceName, len(containers))
}
cmd := c.Args().Slice()[2:]

View File

@ -99,7 +99,19 @@ var appSecretInsertCommand = &cli.Command{
Aliases: []string{"i"},
Usage: "Insert secret",
Flags: []cli.Flag{internal.PassFlag},
ArgsUsage: "<secret> <version> <data>",
ArgsUsage: "<app> <secret-name> <version> <data>",
Description: `
This command inserts a secret into an app environment.
This can be useful when you want to manually generate secrets for an app
environment. Typically, you can let Abra generate them for you on app creation
(see "abra app new --secrets" for more).
Example:
abra app secret insert myapp db_pass v1 mySecretPassword
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
@ -131,12 +143,20 @@ var appSecretRmCommand = &cli.Command{
Usage: "Remove a secret",
Aliases: []string{"rm"},
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
ArgsUsage: "<secret>",
ArgsUsage: "<app> <secret-name>",
Description: `
This command removes a secret from an app environment.
Example:
abra app secret remove myapp db_pass
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if c.Args().Get(1) != "" && allSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret>' and '--all' together"))
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
}
if c.Args().Get(1) == "" && !allSecrets {

View File

@ -5,8 +5,8 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
stack "coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/config"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -22,12 +22,28 @@ volumes as eligiblef or pruning once undeployed.
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
stackName := app.StackName()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("'%s' is not deployed?", stackName)
}
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
logrus.Fatal(err)
}
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
if err := stack.RunRemove(c.Context, cl, rmOpts); err != nil {
logrus.Fatal(err)

179
cli/app/upgrade.go Normal file
View File

@ -0,0 +1,179 @@
package app
import (
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"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"
)
var appUpgradeCommand = &cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade an app",
ArgsUsage: "<app>",
Flags: []cli.Flag{
internal.ForceFlag,
internal.ChaosFlag,
},
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
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.
Chas mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
stackName := app.StackName()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("'%s' is not deployed?", app.Name)
}
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Fatalf("no versions available '%s' in recipe catalogue?", app.Type)
}
var availableUpgrades []string
if deployedVersion == "" {
deployedVersion = "unknown"
availableUpgrades = versions
logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
}
if deployedVersion != "unknown" && !internal.Chaos {
for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
if err != nil {
logrus.Fatal(err)
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
}
if parsedVersion.IsGreaterThan(parsedDeployedVersion) {
availableUpgrades = append(availableUpgrades, version)
}
}
if len(availableUpgrades) == 0 && !internal.Force {
logrus.Fatal("no available upgrades, you're on latest")
availableUpgrades = versions
}
}
var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos {
if internal.Force {
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
logrus.Debugf("choosing '%s' as version to upgrade to", chosenUpgrade)
} else {
prompt := &survey.Select{
Message: fmt.Sprintf("Please select an upgrade (current version: '%s'):", deployedVersion),
Options: availableUpgrades,
}
if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
return err
}
}
}
if !internal.Chaos {
if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
logrus.Fatal(err)
}
}
if internal.Chaos {
logrus.Warn("chaos mode engaged")
var err error
chosenUpgrade, err = recipe.ChaosVersion(app.Type)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade); err != nil {
logrus.Fatal(err)
}
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err)
}
return nil
},
BashComplete: func(c *cli.Context) {
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for _, a := range appNames {
fmt.Println(a)
}
},
}

View File

@ -2,14 +2,14 @@ package app
import (
"fmt"
"sort"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -32,65 +32,61 @@ func getImagePath(image string) (string, error) {
var appVersionCommand = &cli.Command{
Name: "version",
Aliases: []string{"v"},
Usage: "Show version of all services in app",
Usage: "Show app versions",
Description: `
This command shows all information about versioning related to a deployed app.
This includes the individual image names, tags and digests. But also the Co-op
Cloud recipe version.
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
stackName := app.StackName()
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := config.GetAppComposeConfig(app.Type, opts, app.Env)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
ch := make(chan stack.StackStatus, len(compose.Services))
for _, service := range compose.Services {
label := fmt.Sprintf("coop-cloud.%s.%s.version", app.StackName(), service.Name)
go func(s string, l string) {
ch <- stack.GetDeployedServicesByLabel(s, l)
}(app.Server, label)
logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"name", "image", "version", "digest"}
if deployedVersion == "" {
logrus.Fatalf("failed to determine version of deployed '%s'", app.Name)
}
if !isDeployed {
logrus.Fatalf("'%s' is not deployed?", app.Name)
}
recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
if err != nil {
logrus.Fatal(err)
}
versionsMeta := make(map[string]catalogue.ServiceMeta)
for _, recipeVersion := range recipeMeta.Versions {
if currentVersion, exists := recipeVersion[deployedVersion]; exists {
versionsMeta = currentVersion
}
}
if len(versionsMeta) == 0 {
logrus.Fatalf("PANIC: could not retrieve deployed version ('%s') from recipe catalogue?", deployedVersion)
}
tableCol := []string{"name", "image", "version", "tag", "digest"}
table := abraFormatter.CreateTable(tableCol)
statuses := make(map[string]stack.StackStatus)
for range compose.Services {
status := <-ch
if len(status.Services) > 0 {
serviceName := appPkg.ParseServiceName(status.Services[0].Spec.Name)
statuses[serviceName] = status
}
}
sort.SliceStable(compose.Services, func(i, j int) bool {
return compose.Services[i].Name < compose.Services[j].Name
})
for _, service := range compose.Services {
if status, ok := statuses[service.Name]; ok {
statusService := status.Services[0]
label := fmt.Sprintf("coop-cloud.%s.%s.version", app.StackName(), service.Name)
version, digest := appPkg.ParseVersionLabel(statusService.Spec.Labels[label])
image, err := getImagePath(statusService.Spec.Labels["com.docker.stack.image"])
if err != nil {
logrus.Fatal(err)
}
table.Append([]string{service.Name, image, version, digest})
continue
}
image, err := getImagePath(service.Image)
if err != nil {
logrus.Fatal(err)
}
table.Append([]string{service.Name, image, "?", "?"})
for serviceName, versionMeta := range versionsMeta {
table.Append([]string{serviceName, versionMeta.Image, deployedVersion, versionMeta.Tag, versionMeta.Digest})
}
table.Render()
return nil
},
BashComplete: func(c *cli.Context) {

View File

@ -96,7 +96,7 @@ var appVolumeRemoveCommand = &cli.Command{
var appVolumeCommand = &cli.Command{
Name: "volume",
Aliases: []string{"v"},
Aliases: []string{"vl"},
Usage: "Manage app volumes",
ArgsUsage: "<command>",
Subcommands: []*cli.Command{

121
cli/autocomplete.go Normal file
View File

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

View File

@ -7,7 +7,7 @@ import (
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
var CatalogueCommand = &cli.Command{
Name: "catalogue",
Usage: "Manage the recipe catalogue",
Usage: "Manage the recipe catalogue (for maintainers)",
Aliases: []string{"c"},
ArgsUsage: "<recipe>",
Description: "This command helps recipe packagers interact with the recipe catalogue",

View File

@ -8,9 +8,13 @@ import (
"path"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/git"
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"
)
@ -18,9 +22,9 @@ import (
// CatalogueSkipList is all the repos that are not recipes.
var CatalogueSkipList = map[string]bool{
"abra": true,
"abra-bash": true,
"abra-apps": true,
"abra-aur": true,
"abra-bash": true,
"abra-capsul": true,
"abra-gandi": true,
"abra-hetzner": true,
@ -29,6 +33,8 @@ var CatalogueSkipList = map[string]bool{
"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,
@ -46,26 +52,52 @@ var CatalogueSkipList = map[string]bool{
"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",
ArgsUsage: "[<recipe>]",
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Warn(err)
}
if c.NArg() > 0 {
return
}
for name := range catl {
fmt.Println(name)
}
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate a new copy of the catalogue",
Flags: []cli.Flag{
internal.PushFlag,
commitFlag,
internal.CommitMessageFlag,
},
Description: `
This command generates a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README and tags to produce recipe metadata and produces a
apps.json file which is placed in your ~/.abra/catalogue/recipes.json.
It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten.
A new catalogue copy can be published to the recipes repository by passing the
"--commit" and "--push" flags. The recipes repository is available here:
https://git.coopcloud.tech/coop-cloud/recipes
`,
ArgsUsage: "[<recipe>]",
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
repos, err := catalogue.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
@ -73,10 +105,14 @@ var catalogueGenerateCommand = &cli.Command{
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)
@ -90,11 +126,11 @@ var catalogueGenerateCommand = &cli.Command{
recipeDir := path.Join(config.ABRA_DIR, "apps", rm.Name)
if err := git.Clone(recipeDir, rm.SSHURL); err != nil {
if err := gitPkg.Clone(recipeDir, rm.SSHURL); err != nil {
logrus.Fatal(err)
}
if err := git.EnsureUpToDate(recipeDir); err != nil {
if err := gitPkg.EnsureUpToDate(recipeDir); err != nil {
logrus.Fatal(err)
}
@ -133,8 +169,8 @@ var catalogueGenerateCommand = &cli.Command{
Description: recipeMeta.Description,
Website: recipeMeta.Website,
Versions: versions,
// Category: ..., // FIXME: once we sort out the machine-readable catalogue interface
// Features: ..., // FIXME: once we figure out the machine-readable catalogue interface
// Category: ..., // FIXME: parse & load
// Features: ..., // FIXME: parse & load
}
catlBar.Add(1)
}
@ -166,8 +202,61 @@ var catalogueGenerateCommand = &cli.Command{
}
}
logrus.Infof("generated new recipe catalogue in '%s'", config.APPS_JSON)
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

@ -8,7 +8,9 @@ import (
"coopcloud.tech/abra/cli/app"
"coopcloud.tech/abra/cli/catalogue"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/cli/recipe"
"coopcloud.tech/abra/cli/record"
"coopcloud.tech/abra/cli/server"
"coopcloud.tech/abra/pkg/config"
logrusStack "github.com/Gurpartap/logrus-stack"
@ -28,20 +30,7 @@ var VerboseFlag = &cli.BoolFlag{
Usage: "Show INFO messages",
}
// Debug stores the variable from DebugFlag.
var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Value: false,
Destination: &Debug,
Usage: "Show DEBUG messages",
}
// RunApp runs CLI abra app.
func RunApp(version, commit string) {
func newAbraApp(version, commit string) *cli.App {
app := &cli.App{
Name: "abra",
Usage: `The Co-op Cloud command-line utility belt 🎩🐇
@ -59,25 +48,27 @@ func RunApp(version, commit string) {
server.ServerCommand,
recipe.RecipeCommand,
catalogue.CatalogueCommand,
VersionCommand,
record.RecordCommand,
UpgradeCommand,
AutoCompleteCommand,
},
Flags: []cli.Flag{
VerboseFlag,
DebugFlag,
internal.DebugFlag,
internal.NoInputFlag,
},
Authors: []*cli.Author{
{
Name: "Autonomic Co-op",
Email: "helo@autonomic.zone",
},
{Name: "3wordchant"},
{Name: "decentral1se"},
{Name: "knoflook"},
{Name: "roxxers"},
},
}
app.EnableBashCompletion = true
app.Before = func(c *cli.Context) error {
if Debug {
if internal.Debug {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
@ -106,6 +97,12 @@ func RunApp(version, commit string) {
return nil
}
return app
}
// RunApp runs CLI abra app.
func RunApp(version, commit string) {
app := newAbraApp(version, commit)
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)

View File

@ -20,6 +20,10 @@ func Truncate(str string) string {
return fmt.Sprintf(`"%s"`, formatter.Ellipsis(str, 19))
}
func SmallSHA(hash string) string {
return hash[:8]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]

View File

@ -49,3 +49,225 @@ var ForceFlag = &cli.BoolFlag{
Aliases: []string{"f"},
Destination: &Force,
}
// Chaos engages chaos mode.
var Chaos bool
// ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{
Name: "chaos",
Value: false,
Aliases: []string{"ch"},
Usage: "Deploy uncommitted recipes changes. Use with care!",
Destination: &Chaos,
}
// DNSProvider specifies a DNS provider.
var DNSProvider string
// DNSProviderFlag selects a DNS provider.
var DNSProviderFlag = &cli.StringFlag{
Name: "provider",
Value: "",
Aliases: []string{"p"},
Usage: "DNS provider",
Destination: &DNSProvider,
}
var NoInput bool
var NoInputFlag = &cli.BoolFlag{
Name: "no-input",
Value: false,
Aliases: []string{"n"},
Usage: "Toggle non-interactive mode",
Destination: &NoInput,
}
var DNSType string
var DNSTypeFlag = &cli.StringFlag{
Name: "type",
Value: "",
Aliases: []string{"t"},
Usage: "Domain name record type (e.g. A)",
Destination: &DNSType,
}
var DNSName string
var DNSNameFlag = &cli.StringFlag{
Name: "name",
Value: "",
Aliases: []string{"n"},
Usage: "Domain name record name (e.g. mysubdomain)",
Destination: &DNSName,
}
var DNSValue string
var DNSValueFlag = &cli.StringFlag{
Name: "value",
Value: "",
Aliases: []string{"v"},
Usage: "Domain name record value (e.g. 192.168.1.1)",
Destination: &DNSValue,
}
var DNSTTL int
var DNSTTLFlag = &cli.IntFlag{
Name: "ttl",
Value: 86400,
Aliases: []string{"T"},
Usage: "Domain name TTL value)",
Destination: &DNSTTL,
}
var DNSPriority int
var DNSPriorityFlag = &cli.IntFlag{
Name: "priority",
Value: 10,
Aliases: []string{"P"},
Usage: "Domain name priority value",
Destination: &DNSPriority,
}
var ServerProvider string
var ServerProviderFlag = &cli.StringFlag{
Name: "provider",
Aliases: []string{"p"},
Usage: "3rd party server provider",
Destination: &ServerProvider,
}
var CapsulInstanceURL string
var CapsulInstanceURLFlag = &cli.StringFlag{
Name: "capsul-url",
Value: "yolo.servers.coop",
Aliases: []string{"cu"},
Usage: "capsul instance URL",
Destination: &CapsulInstanceURL,
}
var CapsulName string
var CapsulNameFlag = &cli.StringFlag{
Name: "capsul-name",
Value: "",
Aliases: []string{"cn"},
Usage: "capsul name",
Destination: &CapsulName,
}
var CapsulType string
var CapsulTypeFlag = &cli.StringFlag{
Name: "capsul-type",
Value: "f1-xs",
Aliases: []string{"ct"},
Usage: "capsul type",
Destination: &CapsulType,
}
var CapsulImage string
var CapsulImageFlag = &cli.StringFlag{
Name: "capsul-image",
Value: "debian10",
Aliases: []string{"ci"},
Usage: "capsul image",
Destination: &CapsulImage,
}
var CapsulSSHKeys cli.StringSlice
var CapsulSSHKeysFlag = &cli.StringSliceFlag{
Name: "capsul-ssh-keys",
Aliases: []string{"cs"},
Usage: "capsul SSH key",
Destination: &CapsulSSHKeys,
}
var CapsulAPIToken string
var CapsulAPITokenFlag = &cli.StringFlag{
Name: "capsul-token",
Aliases: []string{"ca"},
Usage: "capsul API token",
EnvVars: []string{"CAPSUL_TOKEN"},
Destination: &CapsulAPIToken,
}
var HetznerCloudName string
var HetznerCloudNameFlag = &cli.StringFlag{
Name: "hetzner-name",
Value: "",
Aliases: []string{"hn"},
Usage: "hetzner cloud name",
Destination: &HetznerCloudName,
}
var HetznerCloudType string
var HetznerCloudTypeFlag = &cli.StringFlag{
Name: "hetzner-type",
Aliases: []string{"ht"},
Usage: "hetzner cloud type",
Destination: &HetznerCloudType,
Value: "cx11",
}
var HetznerCloudImage string
var HetznerCloudImageFlag = &cli.StringFlag{
Name: "hetzner-image",
Aliases: []string{"hi"},
Usage: "hetzner cloud image",
Value: "debian-10",
Destination: &HetznerCloudImage,
}
var HetznerCloudSSHKeys cli.StringSlice
var HetznerCloudSSHKeysFlag = &cli.StringSliceFlag{
Name: "hetzner-ssh-keys",
Aliases: []string{"hs"},
Usage: "hetzner cloud SSH keys (e.g. me@foo.com)",
Destination: &HetznerCloudSSHKeys,
}
var HetznerCloudLocation string
var HetznerCloudLocationFlag = &cli.StringFlag{
Name: "hetzner-location",
Aliases: []string{"hl"},
Usage: "hetzner cloud server location",
Value: "hel1",
Destination: &HetznerCloudLocation,
}
var HetznerCloudAPIToken string
var HetznerCloudAPITokenFlag = &cli.StringFlag{
Name: "hetzner-token",
Aliases: []string{"ha"},
Usage: "hetzner cloud API token",
EnvVars: []string{"HCLOUD_TOKEN"},
Destination: &HetznerCloudAPIToken,
}
// Debug stores the variable from DebugFlag.
var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Value: false,
Destination: &Debug,
Usage: "Show DEBUG messages",
}

74
cli/internal/copy.go Normal file
View File

@ -0,0 +1,74 @@
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
}

202
cli/internal/deploy.go Normal file
View File

@ -0,0 +1,202 @@
package internal
import (
"fmt"
"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/recipe"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// DeployAction is the main command-line action for this package
func DeployAction(c *cli.Context) error {
app := ValidateApp(c)
stackName := app.StackName()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
if Force {
logrus.Warnf("'%s' already deployed but continuing (--force)", stackName)
} else if Chaos {
logrus.Warnf("'%s' already deployed but continuing (--chaos)", stackName)
} else {
logrus.Fatalf("'%s' is already deployed", stackName)
}
}
version := deployedVersion
if version == "" && !Chaos {
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type)
if err != nil {
logrus.Fatal(err)
}
if len(versions) > 0 {
version = versions[len(versions)-1]
logrus.Debugf("choosing '%s' as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
} else {
version = "latest commit"
logrus.Warn("no versions detected, using latest commit")
if err := recipe.EnsureLatest(app.Type); err != nil {
logrus.Fatal(err)
}
}
}
if version == "" && !Chaos {
logrus.Debugf("choosing '%s' as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
}
if Chaos {
logrus.Warnf("chaos mode engaged")
var err error
version, err = recipe.ChaosVersion(app.Type)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil {
logrus.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetAppComposeFiles(app.Type, app.Env)
if err != nil {
logrus.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
if err := DeployOverview(app, version, "continue with deployment?"); err != nil {
logrus.Fatal(err)
}
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)
}
if err := stack.RunDeploy(cl, deployOpts, compose); err != nil {
logrus.Fatal(err)
}
return nil
}
// DeployOverview shows a deployment overview
func DeployOverview(app config.App, version, message string) error {
tableCol := []string{"server", "compose", "domain", "stack", "version"}
table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
table.Render()
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{
Message: message,
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
return nil
}
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
table.Render()
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{
Message: "continue with deployment?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
return nil
}

View File

@ -0,0 +1,38 @@
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)
// }

208
cli/internal/new.go Normal file
View File

@ -0,0 +1,208 @@
package internal
import (
"fmt"
"path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/ssh"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
type AppSecrets map[string]string
var Domain string
var DomainFlag = &cli.StringFlag{
Name: "domain",
Aliases: []string{"d"},
Value: "",
Usage: "Choose a domain name",
Destination: &Domain,
}
var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &NewAppServer,
}
var NewAppName string
var NewAppNameFlag = &cli.StringFlag{
Name: "app-name",
Aliases: []string{"a"},
Value: "",
Usage: "Choose an app name",
Destination: &NewAppName,
}
// RecipeName is used for configuring recipe name programmatically
var RecipeName string
// createSecrets creates all secrets for a new app.
func createSecrets(sanitisedAppName string) (AppSecrets, error) {
appEnvPath := path.Join(config.ABRA_DIR, "servers", NewAppServer, fmt.Sprintf("%s.env", sanitisedAppName))
appEnv, err := config.ReadEnv(appEnvPath)
if err != nil {
return nil, err
}
secretEnvVars := secret.ReadSecretEnvVars(appEnv)
secrets, err := secret.GenerateSecrets(secretEnvVars, sanitisedAppName, NewAppServer)
if err != nil {
return nil, err
}
if Pass {
for secretName := range secrets {
secretValue := secrets[secretName]
if err := secret.PassInsertSecret(secretValue, secretName, sanitisedAppName, NewAppServer); err != nil {
return nil, err
}
}
}
return secrets, nil
}
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
func ensureDomainFlag(recipe recipe.Recipe, server string) error {
if Domain == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app domain",
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
}
if err := survey.AskOne(prompt, &Domain); err != nil {
return err
}
}
if Domain == "" {
return fmt.Errorf("no domain provided")
}
return nil
}
// ensureServerFlag checks if the server flag was used. if not, asks the user for it.
func ensureServerFlag() error {
servers, err := config.GetServers()
if err != nil {
return err
}
if NewAppServer == "" && !NoInput {
prompt := &survey.Select{
Message: "Select app server:",
Options: servers,
}
if err := survey.AskOne(prompt, &NewAppServer); err != nil {
return err
}
}
if NewAppServer == "" {
return fmt.Errorf("no server provided")
}
return nil
}
// ensureServerFlag checks if the AppName flag was used. if not, asks the user for it.
func ensureAppNameFlag() error {
if NewAppName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app name:",
Default: config.SanitiseAppName(Domain),
}
if err := survey.AskOne(prompt, &NewAppName); err != nil {
return err
}
}
if NewAppName == "" {
return fmt.Errorf("no app name provided")
}
return nil
}
// NewAction is the new app creation logic
func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c)
if err := config.EnsureAbraDirExists(); err != nil {
logrus.Fatal(err)
}
if err := ensureServerFlag(); err != nil {
logrus.Fatal(err)
}
if err := ensureDomainFlag(recipe, NewAppServer); err != nil {
logrus.Fatal(err)
}
if err := ensureAppNameFlag(); err != nil {
logrus.Fatal(err)
}
sanitisedAppName := config.SanitiseAppName(NewAppName)
if len(sanitisedAppName) > 45 {
logrus.Fatalf("'%s' cannot be longer than 45 characters", sanitisedAppName)
}
logrus.Debugf("'%s' sanitised as '%s' for new app", NewAppName, sanitisedAppName)
if err := config.TemplateAppEnvSample(recipe.Name, NewAppName, NewAppServer, Domain, recipe.Name); err != nil {
logrus.Fatal(err)
}
if Secrets {
if err := ssh.EnsureHostKey(NewAppServer); err != nil {
logrus.Fatal(err)
}
secrets, err := createSecrets(sanitisedAppName)
if err != nil {
logrus.Fatal(err)
}
secretCols := []string{"Name", "Value"}
secretTable := abraFormatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
}
if len(secrets) > 0 {
defer secretTable.Render()
}
}
if NewAppServer == "default" {
NewAppServer = "local"
}
tableCol := []string{"Name", "Domain", "Type", "Server"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
fmt.Println("")
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
fmt.Println("")
table.Render()
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", sanitisedAppName))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", sanitisedAppName))
fmt.Println("")
return nil
}

152
cli/internal/recipe.go Normal file
View File

@ -0,0 +1,152 @@
package internal
import (
"fmt"
"strings"
"coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2"
"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 {
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
`)
var chosenBumpType string
prompt := &survey.Select{
Message: fmt.Sprintf("select recipe version increment type"),
Options: []string{"major", "minor", "patch"},
}
if err := survey.AskOne(prompt, &chosenBumpType); err != nil {
return err
}
SetBumpType(chosenBumpType)
}
return nil
}
// GetBumpType figures out which bump type is specified
func GetBumpType() string {
var bumpType string
if Major {
bumpType = "major"
} else if Minor {
bumpType = "minor"
} else if Patch {
bumpType = "patch"
} else {
logrus.Fatal("no version bump type specififed?")
}
return bumpType
}
// SetBumpType figures out which bump type is specified
func SetBumpType(bumpType string) {
if bumpType == "major" {
Major = true
} else if bumpType == "minor" {
Minor = true
} else if bumpType == "patch" {
Patch = true
} else {
logrus.Fatal("no version bump type specififed?")
}
}
// GetMainApp retrieves the main 'app' image name
func GetMainApp(recipe recipe.Recipe) string {
var app string
for _, service := range recipe.Config.Services {
name := service.Name
if name == "app" {
app = strings.Split(service.Image, ":")[0]
}
}
if app == "" {
logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
}
return app
}

106
cli/internal/record.go Normal file
View File

@ -0,0 +1,106 @@
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
}

208
cli/internal/server.go Normal file
View File

@ -0,0 +1,208 @@
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,14 +2,21 @@ package internal
import (
"errors"
"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"
)
// AppName is used for configuring app name programmatically
var AppName string
// ValidateRecipe ensures the recipe arg is valid.
func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First()
@ -28,10 +35,57 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
return recipe
}
// ValidateRecipeWithPrompt ensures a recipe argument is present before
// validating, asking for input if required.
func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First()
if recipeName == "" && !NoInput {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
var recipes []string
for name := range catl {
recipes = append(recipes, name)
}
prompt := &survey.Select{
Message: "Select recipe",
Options: recipes,
}
if err := survey.AskOne(prompt, &recipeName); err != nil {
logrus.Fatal(err)
}
}
if RecipeName != "" {
recipeName = RecipeName
logrus.Debugf("programmatically setting recipe name to %s", recipeName)
}
if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
}
recipe, err := recipe.Get(recipeName)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as recipe argument", recipeName)
return recipe
}
// ValidateApp ensures the app name arg is valid.
func ValidateApp(c *cli.Context) config.App {
appName := c.Args().First()
if AppName != "" {
appName = AppName
logrus.Debugf("programmatically setting app name to %s", appName)
}
if appName == "" {
ShowSubcommandHelpAndError(c, errors.New("no app provided"))
}
@ -45,20 +99,76 @@ func ValidateApp(c *cli.Context) config.App {
logrus.Fatal(err)
}
if err := ssh.EnsureHostKey(app.Server); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("validated '%s' as app argument", appName)
return app
}
// ValidateDomain ensures the domain name arg is valid.
func ValidateDomain(c *cli.Context) string {
func ValidateDomain(c *cli.Context) (string, error) {
domainName := c.Args().First()
if domainName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify a domain name",
Default: "example.com",
}
if err := survey.AskOne(prompt, &domainName); err != nil {
return domainName, err
}
}
if domainName == "" {
ShowSubcommandHelpAndError(c, errors.New("no domain provided"))
}
logrus.Debugf("validated '%s' as domain argument", domainName)
return domainName
return domainName, nil
}
// ValidateSubCmdFlags ensures flag order conforms to correct order
func ValidateSubCmdFlags(c *cli.Context) bool {
for argIdx, arg := range c.Args().Slice() {
if !strings.HasPrefix(arg, "--") {
for _, flag := range c.Args().Slice()[argIdx:] {
if strings.HasPrefix(flag, "--") {
return false
}
}
}
}
return true
}
// ValidateServer ensures the server name arg is valid.
func ValidateServer(c *cli.Context) (string, error) {
serverName := c.Args().First()
serverNames, err := config.ReadServerNames()
if err != nil {
return serverName, err
}
if serverName == "" && !NoInput {
prompt := &survey.Select{
Message: "Specify a server name",
Options: serverNames,
}
if err := survey.AskOne(prompt, &serverName); err != nil {
return serverName, err
}
}
if serverName == "" {
ShowSubcommandHelpAndError(c, errors.New("no server provided"))
}
logrus.Debugf("validated '%s' as server argument", serverName)
return serverName, nil
}

View File

@ -63,7 +63,14 @@ var recipeLintCommand = &cli.Command{
allImagesTagged = false
}
tag := img.(reference.NamedTagged).Tag()
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
noUnstableTags = false
}
if tag == "latest" {
noUnstableTags = false
}

View File

@ -1,6 +1,7 @@
package recipe
import (
"errors"
"fmt"
"os"
"path"
@ -18,43 +19,56 @@ var recipeNewCommand = &cli.Command{
Usage: "Create a new recipe",
Aliases: []string{"n"},
ArgsUsage: "<recipe>",
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Description: `
This command creates a new recipe.
directory := path.Join(config.APPS_DIR, recipe.Name)
Abra uses our built-in example repository which is available here:
https://git.coopcloud.tech/coop-cloud/example
Files within the example repository make use of the Golang templating system
which Abra uses to inject values into the generated recipe folder (e.g. name of
recipe and domain in the sample environment config).
The new example repository is cloned to ~/.abra/apps/<recipe>.
`,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
if recipeName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
}
directory := path.Join(config.APPS_DIR, recipeName)
if _, err := os.Stat(directory); !os.IsNotExist(err) {
logrus.Fatalf("'%s' recipe directory already exists?", directory)
return nil
}
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
if err := git.Clone(directory, url); err != nil {
return err
logrus.Fatal(err)
}
gitRepo := path.Join(config.APPS_DIR, recipe.Name, ".git")
gitRepo := path.Join(config.APPS_DIR, recipeName, ".git")
if err := os.RemoveAll(gitRepo); err != nil {
logrus.Fatal(err)
return nil
}
logrus.Debugf("removed git repo in '%s'", gitRepo)
toParse := []string{
path.Join(config.APPS_DIR, recipe.Name, "README.md"),
path.Join(config.APPS_DIR, recipe.Name, ".env.sample"),
path.Join(config.APPS_DIR, recipe.Name, ".drone.yml"),
path.Join(config.APPS_DIR, recipeName, "README.md"),
path.Join(config.APPS_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, ".drone.yml"),
}
for _, path := range toParse {
file, err := os.OpenFile(path, os.O_RDWR, 0755)
if err != nil {
logrus.Fatal(err)
return nil
}
tpl, err := template.ParseFiles(path)
if err != nil {
logrus.Fatal(err)
return nil
}
// TODO: ask for description and probably other things so that the
@ -63,15 +77,19 @@ var recipeNewCommand = &cli.Command{
if err := tpl.Execute(file, struct {
Name string
Description string
}{recipe.Name, "TODO"}); err != nil {
}{recipeName, "TODO"}); err != nil {
logrus.Fatal(err)
return nil
}
}
newGitRepo := path.Join(config.APPS_DIR, recipeName)
if err := git.Init(newGitRepo, true); err != nil {
logrus.Fatal(err)
}
logrus.Infof(
"new recipe '%s' created in %s, happy hacking!\n",
recipe.Name, path.Join(config.APPS_DIR, recipe.Name),
recipeName, path.Join(config.APPS_DIR, recipeName),
)
return nil

View File

@ -4,34 +4,10 @@ import (
"github.com/urfave/cli/v2"
)
var Major bool
var MajorFlag = &cli.BoolFlag{
Name: "major",
Value: false,
Aliases: []string{"ma", "x"},
Destination: &Major,
}
var Minor bool
var MinorFlag = &cli.BoolFlag{
Name: "minor",
Value: false,
Aliases: []string{"mi", "y"},
Destination: &Minor,
}
var Patch bool
var PatchFlag = &cli.BoolFlag{
Name: "patch",
Value: false,
Aliases: []string{"p", "z"},
Destination: &Patch,
}
// RecipeCommand defines all recipe related sub-commands.
var RecipeCommand = &cli.Command{
Name: "recipe",
Usage: "Manage recipes",
Usage: "Manage recipes (for maintainers)",
ArgsUsage: "<recipe>",
Aliases: []string{"r"},
Description: `

View File

@ -6,63 +6,25 @@ import (
"strconv"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"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"
)
var Push bool
var PushFlag = &cli.BoolFlag{
Name: "push",
Value: false,
Destination: &Push,
}
var Dry bool
var DryFlag = &cli.BoolFlag{
Name: "dry-run",
Value: false,
Aliases: []string{"d"},
Destination: &Dry,
}
var CommitMessage string
var CommitMessageFlag = &cli.StringFlag{
Name: "commit-message",
Usage: "commit message. Implies --commit",
Aliases: []string{"cm"},
Destination: &CommitMessage,
}
var Commit bool
var CommitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "add compose.yml to staging area and commit changes",
Value: false,
Aliases: []string{"c"},
Destination: &Commit,
}
var TagMessage string
var TagMessageFlag = &cli.StringFlag{
Name: "tag-comment",
Usage: "tag comment. If not given, user will be asked for it",
Aliases: []string{"t", "tm"},
Destination: &TagMessage,
}
var recipeReleaseCommand = &cli.Command{
Name: "release",
Usage: "tag a recipe",
Usage: "Release a new recipe version",
Aliases: []string{"rl"},
ArgsUsage: "<recipe> [<tag>]",
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
@ -83,23 +45,32 @@ 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
`,
Flags: []cli.Flag{
DryFlag,
PatchFlag,
MinorFlag,
MajorFlag,
PushFlag,
CommitFlag,
CommitMessageFlag,
TagMessageFlag,
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.TagMessageFlag,
},
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
recipe := internal.ValidateRecipeWithPrompt(c)
directory := path.Join(config.APPS_DIR, recipe.Name)
tagstring := c.Args().Get(1)
imagesTmp := getImageVersions(recipe)
mainApp := getMainApp(recipe)
tagString := c.Args().Get(1)
mainApp := internal.GetMainApp(recipe)
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
@ -107,26 +78,68 @@ or a rollback of an app.
}
if mainAppVersion == "" {
logrus.Fatal("main app version is empty?")
logrus.Fatalf("main 'app' service version for %s is empty?", recipe.Name)
}
if tagstring != "" {
if _, err := tagcmp.Parse(tagstring); err != nil {
if tagString != "" {
if _, err := tagcmp.Parse(tagString); err != nil {
logrus.Fatal("invalid tag specified")
}
}
if TagMessage == "" {
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)
}
survey.AskOne(prompt, &TagMessage)
}
var createTagOptions git.CreateTagOptions
createTagOptions.Message = TagMessage
createTagOptions.Message = internal.TagMessage
if Commit || (CommitMessage != "") {
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)
@ -136,23 +149,31 @@ or a rollback of an app.
logrus.Fatal(err)
}
if CommitMessage == "" {
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)
}
survey.AskOne(prompt, &CommitMessage)
}
err = commitWorktree.AddGlob("compose.**yml")
if err != nil {
logrus.Fatal(err)
}
logrus.Debug("staged compose.**yml for commit")
_, err = commitWorktree.Commit(CommitMessage, &git.CommitOptions{})
if err != nil {
logrus.Fatal(err)
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")
}
logrus.Info("changes commited")
}
repo, err := git.PlainOpen(directory)
@ -164,20 +185,8 @@ or a rollback of an app.
logrus.Fatal(err)
}
// bumpType is used to decide what part of the tag should be incremented
bumpType := btoi(Major)*4 + btoi(Minor)*2 + btoi(Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
}
}
if tagstring != "" {
if bumpType > 0 {
logrus.Warn("user specified a version number and --major/--minor/--patch at the same time! using version number...")
}
tag, err := tagcmp.Parse(tagstring)
if tagString != "" {
tag, err := tagcmp.Parse(tagString)
if err != nil {
logrus.Fatal(err)
}
@ -189,19 +198,23 @@ or a rollback of an app.
tag.Patch = "0"
tag.MissingPatch = false
}
tagstring = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
if Dry {
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagstring, head.Hash()))
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)
logrus.Info(fmt.Sprintf("created tag %s at %s", tagstring, head.Hash()))
if Push {
repo.CreateTag(tagString, head.Hash(), &createTagOptions)
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
if internal.Push && !internal.Dry {
if err := repo.Push(&git.PushOptions{}); err != nil {
logrus.Fatal(err)
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagstring))
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
} else {
logrus.Info("dry run only: NOT pushing changes")
}
return nil
@ -232,25 +245,23 @@ or a rollback of an app.
logrus.Fatal(err)
}
fmt.Println(lastGitTag)
newTag := lastGitTag
var newTagString string
var newtagString string
if bumpType > 0 {
if Patch {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if Minor {
} 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 Major {
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
logrus.Fatal(err)
@ -259,54 +270,70 @@ or a rollback of an app.
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
} else {
logrus.Fatal("we don't support automatic tag generation yet - specify a version or use one of: --major --minor --patch")
}
newTag.Metadata = mainAppVersion
newTagString = newTag.String()
if Dry {
logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newTagString, head.Hash()))
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)
logrus.Info(fmt.Sprintf("created tag %s at %s", newTagString, head.Hash()))
if Push {
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))
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString))
} else {
logrus.Info("gry run only: NOT pushing changes")
}
return nil
},
}
func getImageVersions(recipe recipe.Recipe) map[string]string {
// getImageVersions retrieves image versions for a recipe
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
var services = make(map[string]string)
for _, service := range recipe.Config.Services {
srv := strings.Split(service.Image, ":")
services[srv[0]] = srv[1]
}
return services
}
func getMainApp(recipe recipe.Recipe) string {
for _, service := range recipe.Config.Services {
name := service.Name
if name == "app" {
return strings.Split(service.Image, ":")[0]
if service.Image == "" {
continue
}
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return services, 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.Fatalf("%s service is missing image tag?", path)
}
services[path] = tag
}
return ""
return services, nil
}
// btoi converts a boolean value into an integer
func btoi(b bool) int {
if b {
return 1
}
return 0
}

View File

@ -1,12 +1,17 @@
package recipe
import (
"errors"
"fmt"
"path"
"strconv"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"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"
)
@ -15,18 +20,167 @@ var recipeSyncCommand = &cli.Command{
Name: "sync",
Usage: "Ensure recipe version labels are up-to-date",
Aliases: []string{"s"},
ArgsUsage: "<recipe> <version>",
ArgsUsage: "<recipe> [<version>]",
Flags: []cli.Flag{
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
},
Description: `
This command will generate labels for the main recipe service (i.e. by
convention, typically the service named "app") which corresponds to the
following format:
convention, the service named "app") which corresponds to the following format:
coop-cloud.${STACK_NAME}.version=<version>
The <version> is determined by the recipe maintainer and is specified on the
command-line. The <recipe> configuration will be updated on the local file
system.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe sync
`,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
mainApp := internal.GetMainApp(recipe)
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
tags, err := recipe.Tags()
if err != nil {
logrus.Fatal(err)
}
nextTag := c.Args().Get(1)
if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no tags found for %s", 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 {
logrus.Fatal(err)
}
}
if nextTag == "" {
recipeDir := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
logrus.Fatal(err)
}
var lastGitTag tagcmp.Tag
iter, err := repo.Tags()
if err != nil {
logrus.Fatal(err)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash())
if err != nil {
return err
}
tagcmpTag, err := tagcmp.Parse(obj.Name)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = tagcmpTag
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
lastGitTag = tagcmpTag
}
return nil
}); err != nil {
logrus.Fatal(err)
}
// bumpType is used to decide what part of the tag should be incremented
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
}
}
newTag := lastGitTag
if bumpType > 0 {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
logrus.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
}
newTag.Metadata = mainAppVersion
logrus.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
nextTag = newTag.String()
}
if _, err := tagcmp.Parse(nextTag); err != nil {
logrus.Fatalf("invalid version %s specified", nextTag)
}
mainService := "app"
var services []string
hasAppService := false
for _, service := range recipe.Config.Services {
services = append(services, service.Name)
if service.Name == "app" {
hasAppService = true
logrus.Debugf("detected app service in %s", recipe.Name)
}
}
if !hasAppService {
logrus.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 {
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)
}
return nil
},
BashComplete: func(c *cli.Context) {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
@ -39,50 +193,4 @@ system.
fmt.Println(name)
}
},
Action: func(c *cli.Context) error {
if c.Args().Len() != 2 {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <recipe>/<version> arguments?"))
}
recipe := internal.ValidateRecipe(c)
// TODO: validate with tagcmp when new commits come in
// See https://git.coopcloud.tech/coop-cloud/abra/pulls/109
nextTag := c.Args().Get(1)
mainService := "app"
var services []string
hasAppService := false
for _, service := range recipe.Config.Services {
services = append(services, service.Name)
if service.Name == "app" {
hasAppService = true
logrus.Debugf("detected app service in '%s'", recipe.Name)
}
}
if !hasAppService {
logrus.Warnf("no 'app' service defined in '%s'", recipe.Name)
var chosenService string
prompt := &survey.Select{
Message: fmt.Sprintf("what is the main service name for '%s'?", recipe.Name),
Options: services,
}
if err := survey.AskOne(prompt, &chosenService); err != nil {
logrus.Fatal(err)
}
mainService = chosenService
}
logrus.Debugf("selecting '%s' as the service to sync version labels", mainService)
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
if err := recipe.UpdateLabel(mainService, label); err != nil {
logrus.Fatal(err)
}
logrus.Infof("synced label '%s' to service '%s'", label, mainService)
return nil
},
}

View File

@ -1,13 +1,17 @@
package recipe
import (
"bufio"
"fmt"
"os"
"path"
"sort"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
@ -15,6 +19,11 @@ import (
"github.com/urfave/cli/v2"
)
type imgPin struct {
image string
version tagcmp.Tag
}
var recipeUpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade recipe image tags",
@ -27,17 +36,22 @@ update the relevant compose file tags on the local file system.
Some image tags cannot be parsed because they do not follow some sort of
semver-like convention. In this case, all possible tags will be listed and it
is up to the end-user to decide.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe upgrade
`,
ArgsUsage: "<recipe>",
Flags: []cli.Flag{
PatchFlag,
MinorFlag,
MajorFlag,
internal.PatchFlag,
internal.MinorFlag,
internal.MajorFlag,
},
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
recipe := internal.ValidateRecipeWithPrompt(c)
bumpType := btoi(Major)*4 + btoi(Minor)*2 + btoi(Patch)
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
@ -45,6 +59,43 @@ is up to the end-user to decide.
}
}
// check for versions file and load pinned versions
versionsPresent := false
recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
versionsPath := path.Join(recipeDir, "versions")
var servicePins = make(map[string]imgPin)
if _, err := os.Stat(versionsPath); err == nil {
logrus.Debugf("found versions file for %s", recipe.Name)
file, err := os.Open(versionsPath)
if err != nil {
logrus.Fatal(err)
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
splitLine := strings.Split(line, " ")
if splitLine[0] != "pin" || len(splitLine) != 3 {
logrus.Fatalf("malformed version pin specification: %s", line)
}
pinSlice := strings.Split(splitLine[2], ":")
pinTag, err := tagcmp.Parse(pinSlice[1])
if err != nil {
logrus.Fatal(err)
}
pin := imgPin{
image: pinSlice[0],
version: pinTag,
}
servicePins[splitLine[1]] = pin
}
if err := scanner.Err(); err != nil {
logrus.Error(err)
}
versionsPresent = true
} else {
logrus.Debugf("did not find versions file for %s", recipe.Name)
}
for _, service := range recipe.Config.Services {
catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
if err != nil {
@ -69,7 +120,6 @@ is up to the end-user to decide.
// first position of the string
image = strings.Split(image, "/")[1]
}
semverLikeTag := true
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
logrus.Debugf("'%s' not considered semver-like", img.(reference.NamedTagged).Tag())
@ -81,7 +131,6 @@ is up to the end-user to decide.
logrus.Fatal(err)
}
logrus.Debugf("parsed '%s' for '%s'", tag, service.Name)
var compatible []tagcmp.Tag
for _, regVersion := range regVersions {
other, err := tagcmp.Parse(regVersion.Name)
@ -117,44 +166,69 @@ is up to the end-user to decide.
}
logrus.Debugf("detected compatible upgradable tags '%s' for '%s'", compatibleStrings, service.Name)
var upgradeTag string
if bumpType != 0 {
for _, upTag := range compatible {
upElement, err := tag.UpgradeDelta(upTag)
if err != nil {
return err
_, ok := servicePins[service.Name]
if versionsPresent && ok {
pinnedTag := servicePins[service.Name].version
if tag.IsLessThan(pinnedTag) {
pinnedTagString := pinnedTag.String()
contains := false
for _, v := range compatible {
if pinnedTag.IsUpgradeCompatible(v) {
contains = true
upgradeTag = v.String()
break
}
}
delta := upElement.UpgradeType()
if delta <= bumpType {
upgradeTag = upTag.String()
break
if contains {
logrus.Infof("Upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
} else {
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
continue
}
}
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)
} else {
logrus.Fatalf("Service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
continue
}
} else {
msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
tag := img.(reference.NamedTagged).Tag()
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{}
for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion.Name)
if bumpType != 0 {
for _, upTag := range compatible {
upElement, err := tag.UpgradeDelta(upTag)
if err != nil {
return err
}
delta := upElement.UpgradeType()
if delta <= bumpType {
upgradeTag = upTag.String()
break
}
}
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)
continue
}
} else {
msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
tag := img.(reference.NamedTagged).Tag()
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{}
for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion.Name)
}
}
prompt := &survey.Select{
Message: msg,
Options: compatibleStrings,
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
logrus.Fatal(err)
}
}
prompt := &survey.Select{
Message: msg,
Options: compatibleStrings,
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.UpdateTag(image, upgradeTag); err != nil {
logrus.Fatal(err)
}

79
cli/record/list.go Normal file
View File

@ -0,0 +1,79 @@
package record
import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/libdns/gandi"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordListCommand lists domains.
var RecordListCommand = &cli.Command{
Name: "list",
Usage: "List domain name records",
Aliases: []string{"ls"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
},
Description: `
This command lists all domain name records managed by a 3rd party provider for
a specific zone.
You must specify a zone (e.g. example.com) under which your domain name records
are listed. This zone must already be created on your provider account.
`,
Action: func(c *cli.Context) error {
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
for _, record := range records {
value := record.Value
if len(record.Value) > 30 {
value = fmt.Sprintf("%s...", record.Value[:30])
}
table.Append([]string{
record.Type,
record.Name,
value,
record.TTL.String(),
strconv.Itoa(record.Priority),
})
}
table.Render()
return nil
},
}

136
cli/record/new.go Normal file
View File

@ -0,0 +1,136 @@
package record
import (
"fmt"
"strconv"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordNewCommand creates a new domain name record.
var RecordNewCommand = &cli.Command{
Name: "new",
Usage: "Create a new domain record",
Aliases: []string{"n"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
internal.DNSTypeFlag,
internal.DNSNameFlag,
internal.DNSValueFlag,
internal.DNSTTLFlag,
internal.DNSPriorityFlag,
},
Description: `
This command creates a new domain name record for a specific zone.
You must specify a zone (e.g. example.com) under which your domain name records
are listed. This zone must already be created on your provider account.
Example:
abra record new foo.com -p gandi -t A -n myapp -v 192.168.178.44
You may also invoke this command in "wizard" mode and be prompted for input
abra record new
`,
Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSNameFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSValueFlag(c); err != nil {
logrus.Fatal(err)
}
record := libdns.Record{
Type: internal.DNSType,
Name: internal.DNSName,
Value: internal.DNSValue,
TTL: time.Duration(internal.DNSTTL),
}
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
record.Priority = internal.DNSPriority
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
for _, existingRecord := range records {
if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name &&
existingRecord.Value == record.Value {
logrus.Fatal("provider library reports that this record already exists?")
}
}
createdRecords, err := provider.SetRecords(
c.Context,
zone,
[]libdns.Record{record},
)
if len(createdRecords) == 0 {
logrus.Fatal("provider library reports that no record was created?")
}
createdRecord := createdRecords[0]
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
value := createdRecord.Value
if len(createdRecord.Value) > 30 {
value = fmt.Sprintf("%s...", createdRecord.Value[:30])
}
table.Append([]string{
createdRecord.Type,
createdRecord.Name,
value,
createdRecord.TTL.String(),
strconv.Itoa(createdRecord.Priority),
})
table.Render()
logrus.Info("record created")
return nil
},
}

38
cli/record/record.go Normal file
View File

@ -0,0 +1,38 @@
package record
import (
"github.com/urfave/cli/v2"
)
// RecordCommand supports managing DNS entries.
var RecordCommand = &cli.Command{
Name: "record",
Usage: "Manage domain name records via 3rd party providers",
Aliases: []string{"rc"},
ArgsUsage: "<record>",
Description: `
This command supports managing domain name records via 3rd party providers such
as Gandi DNS. It supports listing, creating and removing all types of records
that you might need for managing Co-op Cloud apps.
The following providers are supported:
Gandi DNS https://www.gandi.net
You need an account with such a provider already. Typically, you need to
provide an API token on the Abra command-line when using these commands so that
you can authenticate with your provider account.
New providers can be integrated, we welcome change sets. See the underlying DNS
library documentation for more. It supports many existing providers and allows
to implement new provider support easily.
https://pkg.go.dev/github.com/libdns/libdns
`,
Subcommands: []*cli.Command{
RecordListCommand,
RecordNewCommand,
RecordRemoveCommand,
},
}

130
cli/record/remove.go Normal file
View File

@ -0,0 +1,130 @@
package record
import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"github.com/AlecAivazis/survey/v2"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// RecordRemoveCommand lists domains.
var RecordRemoveCommand = &cli.Command{
Name: "remove",
Usage: "Remove a domain name record",
Aliases: []string{"rm"},
ArgsUsage: "<zone>",
Flags: []cli.Flag{
internal.DNSProviderFlag,
internal.DNSTypeFlag,
internal.DNSNameFlag,
},
Description: `
This command removes a domain name record for a specific zone.
It uses the type of record and name to match existing records and choose one
for deletion. You must specify a zone (e.g. example.com) under which your
domain name records are listed. This zone must already be created on your
provider account.
Example:
abra record remove foo.com -p gandi -t A -n myapp
You may also invoke this command in "wizard" mode and be prompted for input
abra record rm
`,
Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c)
if err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSProvider(); err != nil {
logrus.Fatal(err)
}
var provider gandi.Provider
switch internal.DNSProvider {
case "gandi":
provider, err = gandiPkg.New()
if err != nil {
logrus.Fatal(err)
}
default:
logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
}
if err := internal.EnsureDNSTypeFlag(c); err != nil {
logrus.Fatal(err)
}
if err := internal.EnsureDNSNameFlag(c); err != nil {
logrus.Fatal(err)
}
records, err := provider.GetRecords(c.Context, zone)
if err != nil {
logrus.Fatal(err)
}
var toDelete libdns.Record
for _, record := range records {
if record.Type == internal.DNSType && record.Name == internal.DNSName {
toDelete = record
break
}
}
if (libdns.Record{}) == toDelete {
logrus.Fatal("provider library reports no matching record?")
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := abraFormatter.CreateTable(tableCol)
value := toDelete.Value
if len(toDelete.Value) > 30 {
value = fmt.Sprintf("%s...", toDelete.Value[:30])
}
table.Append([]string{
toDelete.Type,
toDelete.Name,
value,
toDelete.TTL.String(),
strconv.Itoa(toDelete.Priority),
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with record deletion?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
_, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})
if err != nil {
logrus.Fatal(err)
}
logrus.Info("record successfully deleted")
return nil
},
}

View File

@ -1,35 +1,374 @@
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"
contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/server"
"coopcloud.tech/abra/pkg/ssh"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var (
dockerInstallMsg = `
A docker installation cannot be found on %s. This is a required system
dependency for running Co-op Cloud on your server. If you would like, Abra can
attempt to install Docker for you using the upstream non-interactive
installation script.
See the following documentation for more:
https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script
N.B Docker doesn't recommend it for production environments but many use it for
such purposes. Docker stable is now installed by default by this script. The
source for this script can be seen here:
https://github.com/docker/docker-install
`
)
var local bool
var localFlag = &cli.BoolFlag{
Name: "local",
Aliases: []string{"L"},
Aliases: []string{"l"},
Value: false,
Usage: "Set up the local server",
Usage: "Use local server",
Destination: &local,
}
var provision bool
var provisionFlag = &cli.BoolFlag{
Name: "provision",
Aliases: []string{"p"},
Value: false,
Usage: "Provision server so it can deploy apps",
Destination: &provision,
}
var sshAuth string
var sshAuthFlag = &cli.StringFlag{
Name: "ssh-auth",
Aliases: []string{"sh"},
Value: "identity-file",
Usage: "Select SSH authentication method (identity-file, password)",
Destination: &sshAuth,
}
var askSudoPass bool
var askSudoPassFlag = &cli.BoolFlag{
Name: "ask-sudo-pass",
Aliases: []string{"as"},
Value: false,
Usage: "Ask for sudo password",
Destination: &askSudoPass,
}
var traefik bool
var traefikFlag = &cli.BoolFlag{
Name: "traefik",
Aliases: []string{"t"},
Value: false,
Usage: "Deploy traefik",
Destination: &traefik,
}
func cleanUp(domainName string) {
logrus.Warnf("cleaning up context for %s", domainName)
if err := client.DeleteContext(domainName); err != nil {
logrus.Fatal(err)
}
logrus.Warnf("cleaning up server directory for %s", domainName)
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, domainName)); err != nil {
logrus.Fatal(err)
}
}
func installDockerLocal(c *cli.Context) error {
fmt.Println(fmt.Sprintf(dockerInstallMsg, "this local server"))
response := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("attempt install docker on local server?"),
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
cmd := exec.Command("bash", "-c", "curl -s https://get.docker.com | bash")
if err := internal.RunCmd(cmd); err != nil {
return err
}
return nil
}
func newLocalServer(c *cli.Context, domainName string) error {
if err := createServerDir(domainName); err != nil {
return err
}
cl, err := newClient(c, domainName)
if err != nil {
return err
}
if provision {
out, err := exec.Command("which", "docker").Output()
if err != nil {
return err
}
if string(out) == "" {
if err := installDockerLocal(c); err != nil {
return err
}
}
if err := initSwarmLocal(c, cl, domainName); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
logrus.Fatal(err)
}
}
}
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
return err
}
}
logrus.Info("local server has been added")
return nil
}
func newContext(c *cli.Context, domainName, username, port string) error {
store := contextPkg.NewDefaultDockerContextStore()
contexts, err := store.Store.List()
if err != nil {
return err
}
for _, context := range contexts {
if context.Name == domainName {
logrus.Debugf("context for %s already exists", domainName)
return nil
}
}
logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port)
if err := client.CreateContext(domainName, username, port); err != nil {
return err
}
return nil
}
func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) {
cl, err := client.New(domainName)
if err != nil {
return &dockerClient.Client{}, err
}
return cl, nil
}
func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error {
result, err := sshCl.Exec("which docker")
if err != nil && string(result) != "" {
return err
}
if string(result) == "" {
fmt.Println(fmt.Sprintf(dockerInstallMsg, domainName))
response := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("attempt install docker on %s?", domainName),
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
cmd := "curl -s https://get.docker.com | bash"
var sudoPass string
if askSudoPass {
prompt := &survey.Password{
Message: "sudo password?",
}
if err := survey.AskOne(prompt, &sudoPass); err != nil {
return err
}
logrus.Debugf("running '%s' on %s now with sudo password", cmd, domainName)
if err := ssh.RunSudoCmd(cmd, sudoPass, sshCl); err != nil {
return err
}
} else {
logrus.Debugf("running '%s' on %s now without sudo password", cmd, domainName)
if err := ssh.Exec(cmd, sshCl); err != nil {
return err
}
}
}
logrus.Infof("docker is installed on %s", domainName)
return nil
}
func initSwarmLocal(c *cli.Context, cl *dockerClient.Client, domainName string) error {
initReq := swarm.InitRequest{ListenAddr: "0.0.0.0:2377"}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") {
return err
}
logrus.Info("swarm mode already initialised on local server")
} else {
logrus.Infof("initialised swarm mode on local server")
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
logrus.Info("swarm overlay network already created on local server")
} else {
logrus.Infof("swarm overlay network created on local server")
}
return nil
}
func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error {
ipv4, err := dns.EnsureIPv4(domainName)
if err != nil {
return err
}
initReq := swarm.InitRequest{
ListenAddr: "0.0.0.0:2377",
AdvertiseAddr: ipv4,
}
if _, err := cl.SwarmInit(c.Context, initReq); err != nil {
if !strings.Contains(err.Error(), "is already part of a swarm") {
return err
}
logrus.Infof("swarm mode already initialised on %s", domainName)
} else {
logrus.Infof("initialised swarm mode on %s", domainName)
}
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil {
if !strings.Contains(err.Error(), "proxy already exists") {
return err
}
logrus.Infof("swarm overlay network already created on %s", domainName)
} else {
logrus.Infof("swarm overlay network created on %s", domainName)
}
return nil
}
func createServerDir(domainName string) error {
if err := server.CreateServerDir(domainName); err != nil {
if !os.IsExist(err) {
return err
}
logrus.Debugf("server dir for %s already created", domainName)
}
return nil
}
func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error {
internal.NoInput = true
internal.RecipeName = "traefik"
internal.NewAppServer = domainName
internal.Domain = fmt.Sprintf("%s.%s", "traefik", domainName)
internal.NewAppName = fmt.Sprintf("%s_%s", "traefik", config.SanitiseAppName(domainName))
appEnvPath := path.Join(config.ABRA_DIR, "servers", internal.Domain, fmt.Sprintf("%s.env", internal.NewAppName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
fmt.Println(fmt.Sprintf(`
You specified "--traefik/-t" and that means that Abra will now try to
automatically create a new Traefik app on %s.
`, internal.NewAppServer))
tableCol := []string{"recipe", "domain", "server", "name"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{internal.RecipeName, internal.Domain, internal.NewAppServer, internal.NewAppName})
if err := internal.NewAction(c); err != nil {
logrus.Fatal(err)
}
} else {
logrus.Infof("%s already exists, not creating again", appEnvPath)
}
internal.AppName = internal.NewAppName
if err := internal.DeployAction(c); err != nil {
logrus.Fatal(err)
}
return nil
}
var serverAddCommand = &cli.Command{
Name: "add",
Usage: "Add a new server",
Usage: "Add a server to your configuration",
Description: `
This command adds a new server that abra will communicate with, to deploy apps.
This command adds a new server to your configuration so that it can be managed
by Abra. This can be useful when you already have a server provisioned and want
to start running Abra commands against it.
This command can also provision your server ("--provision/-p") so that it is
capable of hosting Co-op Cloud apps. Abra will default to expecting that you
have a running ssh-agent and are using SSH keys to connect to your new server.
Abra will also read your SSH config (matching "Host" as <domain>). SSH
connection details precedence follows as such: command-line > SSH config >
guessed defaults.
If you have no SSH key configured for this host and are instead using password
authentication, you may pass "--ssh-auth password" to have Abra ask you for the
password. "--ask-sudo-pass" may be passed if you run your provisioning commands
via sudo privilege escalation.
If "--local" is passed, then Abra assumes that the current local server is
intended as the target server.
intended as the target server. This is useful when you want to have your entire
Co-op Cloud config located on the server itself, and not on your local
developer machine.
Example:
abra server add --local
Otherwise, you may specify a remote server. The <domain> argument must be a
publicy accessible domain name which points to your server. You should have SSH
@ -37,102 +376,111 @@ access to this server, Abra will assume port 22 and will use your current
system username to make an initial connection. You can use the <user> and
<port> arguments to adjust this.
For example:
Example:
abra server add varia.zone glodemodem 12345
abra server add --provision --traefik varia.zone glodemodem 12345
Abra will construct the following SSH connection string then:
Abra will construct the following SSH connection and Docker context:
ssh://globemodem@varia.zone:12345
All communication between Abra and the server will use this SSH connection.
In this example, Abra will run the following operations:
1. Install Docker
2. Initialise Swarm mode
3. Deploy Traefik (core web proxy)
You may omit flags to avoid performing this provisioning logic.
`,
Aliases: []string{"a"},
Flags: []cli.Flag{
localFlag,
provisionFlag,
sshAuthFlag,
askSudoPassFlag,
traefikFlag,
},
ArgsUsage: "<domain> [<user>] [<port>]",
Action: func(c *cli.Context) error {
if c.Args().Len() == 1 && !local {
err := errors.New("missing arguments <domain> or '--local'")
internal.ShowSubcommandHelpAndError(c, err)
}
if c.Args().Get(1) != "" && local {
if c.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(c) {
err := errors.New("cannot use '<domain>' and '--local' together")
internal.ShowSubcommandHelpAndError(c, err)
}
domainName := "default"
if sshAuth != "password" && sshAuth != "identity-file" {
err := errors.New("--ssh-auth only accepts 'identity-file' or 'password'")
internal.ShowSubcommandHelpAndError(c, err)
}
if local {
if err := server.CreateServerDir(domainName); err != nil {
if err := newLocalServer(c, "default"); err != nil {
logrus.Fatal(err)
}
logrus.Info("local server has been added")
return nil
}
domainName = internal.ValidateDomain(c)
domainName, err := internal.ValidateDomain(c)
if err != nil {
logrus.Fatal(err)
}
var username string
var port string
username = c.Args().Get(1)
username := c.Args().Get(1)
if username == "" {
systemUser, err := user.Current()
if err != nil {
logrus.Fatal(err)
return err
}
username = systemUser.Username
}
port = c.Args().Get(2)
port := c.Args().Get(2)
if port == "" {
port = "22"
}
store := client.NewDefaultDockerContextStore()
contexts, err := store.Store.List()
if err := createServerDir(domainName); err != nil {
logrus.Fatal(err)
}
if err := newContext(c, domainName, username, port); err != nil {
logrus.Fatal(err)
}
cl, err := newClient(c, domainName)
if err != nil {
logrus.Fatal(err)
}
for _, context := range contexts {
if context.Name == domainName {
logrus.Fatalf("server at '%s' already exists?", 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)
}
defer sshCl.Close()
logrus.Debugf("successfully created SSH client for %s", domainName)
if err := installDocker(c, cl, sshCl, domainName); err != nil {
logrus.Fatal(err)
}
if err := initSwarm(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
}
logrus.Debugf("creating context with domain '%s', username '%s' and port '%s'", domainName, username, port)
if err := client.CreateContext(domainName, username, port); err != nil {
logrus.Fatal(err)
if _, err := cl.Info(c.Context); err != nil {
cleanUp(domainName)
logrus.Fatalf("couldn't make a remote docker connection to %s? use --provision/-p to attempt to install", domainName)
}
ctx := context.Background()
cl, err := client.New(domainName)
if err != nil {
logrus.Fatal(err)
}
if _, err := cl.Info(ctx); err != nil {
if strings.Contains(err.Error(), "command not found") {
logrus.Fatalf("docker is not installed on '%s'?", domainName)
} else {
logrus.Fatalf("unable to make a connection to '%s'?", domainName)
if traefik {
if err := deployTraefik(c, cl, domainName); err != nil {
logrus.Fatal(err)
}
logrus.Debug(err)
}
logrus.Debugf("remote connection to '%s' is definitely up", domainName)
if err := server.CreateServerDir(domainName); err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at '%s' has been added", domainName)
return nil
},
}

View File

@ -1,81 +0,0 @@
package server
import (
"context"
"fmt"
"net"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var serverInitCommand = &cli.Command{
Name: "init",
Usage: "Initialise server for deploying apps",
Aliases: []string{"i"},
HideHelp: true,
ArgsUsage: "<domain>",
Description: `
Initialise swarm mode on the target <domain>.
This initialisation explicitly chooses the "single host swarm" mode which uses
the default IPv4 address as the advertising address. This can be re-configured
later for more advanced use cases.
`,
Action: func(c *cli.Context) error {
domainName := internal.ValidateDomain(c)
cl, err := client.New(domainName)
if err != nil {
return err
}
// https://www.privacy-handbuch.de/handbuch_93d.htm
freifunkDNS := "5.1.66.255:53"
resolver := &net.Resolver{
PreferGo: false,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
// comrade librehosters DNS resolver https://snopyta.org/service/dns/
return d.DialContext(ctx, "udp", freifunkDNS)
},
}
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
ctx := context.Background()
ips, err := resolver.LookupIPAddr(ctx, domainName)
if err != nil {
logrus.Fatal(err)
}
if len(ips) == 0 {
return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
}
ipv4 := ips[0].IP.To4().String()
initReq := swarm.InitRequest{
ListenAddr: "0.0.0.0:2377",
AdvertiseAddr: ipv4,
}
if _, err := cl.SwarmInit(ctx, initReq); err != nil {
return err
}
logrus.Debugf("initialised swarm on '%s'", domainName)
netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"}
if _, err := cl.NetworkCreate(ctx, "proxy", netOpts); err != nil {
return err
}
logrus.Debug("swarm overlay network 'proxy' created")
return nil
},
}

View File

@ -4,8 +4,9 @@ import (
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/context"
"github.com/docker/cli/cli/connhelper/ssh"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -17,13 +18,13 @@ var serverListCommand = &cli.Command{
ArgsUsage: " ",
HideHelp: true,
Action: func(c *cli.Context) error {
dockerContextStore := client.NewDefaultDockerContextStore()
dockerContextStore := context.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List()
if err != nil {
logrus.Fatal(err)
}
tableColumns := []string{"Name", "Connection"}
tableColumns := []string{"name", "host", "user", "port"}
table := formatter.CreateTable(tableColumns)
defer table.Render()
@ -35,20 +36,24 @@ var serverListCommand = &cli.Command{
for _, serverName := range serverNames {
var row []string
for _, ctx := range contexts {
endpoint, err := client.GetContextEndpoint(ctx)
endpoint, err := context.GetContextEndpoint(ctx)
if err != nil && strings.Contains(err.Error(), "does not exist") {
// No local context found, we can continue safely
continue
}
if ctx.Name == serverName {
row = []string{serverName, endpoint}
sp, err := ssh.ParseURL(endpoint)
if err != nil {
logrus.Fatal(err)
}
row = []string{serverName, sp.Host, sp.User, sp.Port}
}
}
if len(row) == 0 {
if serverName == "default" {
row = []string{serverName, "local"}
row = []string{serverName, "local", "n/a", "n/a"}
} else {
row = []string{serverName, "unknown"}
row = []string{serverName, "unknown", "unknown", "unknown"}
}
}
table.Append(row)

View File

@ -1,251 +1,177 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"time"
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/libcapsul"
"github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var hetznerCloudType string
var hetznerCloudImage string
var hetznerCloudSSHKeys cli.StringSlice
var hetznerCloudLocation string
var hetznerCloudAPIToken string
var serverNewHetznerCloudCommand = &cli.Command{
Name: "hetzner",
Usage: "Create a new Hetzner virtual server",
ArgsUsage: "<name>",
Description: `
Create a new Hetzner virtual server.
func newHetznerCloudVPS(c *cli.Context) error {
if err := internal.EnsureNewHetznerCloudVPSFlags(c); err != nil {
return err
}
This command uses the uses the Hetzner Cloud API bindings to send a server
creation request. You must already have a Hetzner Cloud account and an account
API token before using this command.
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
Your token can be loaded from the environment using the HCLOUD_TOKEN
environment variable or otherwise passing the "--env/-e" flag.
`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Server type",
Destination: &hetznerCloudType,
Value: "cx11",
},
&cli.StringFlag{
Name: "image",
Aliases: []string{"i"},
Usage: "Image type",
Value: "debian-10",
Destination: &hetznerCloudImage,
},
&cli.StringSliceFlag{
Name: "ssh-keys",
Aliases: []string{"s"},
Usage: "SSH keys",
Destination: &hetznerCloudSSHKeys,
},
&cli.StringFlag{
Name: "location",
Aliases: []string{"l"},
Usage: "Server location",
Value: "hel1",
Destination: &hetznerCloudLocation,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"T"},
Usage: "Hetzner Cloud API token",
EnvVars: []string{"HCLOUD_TOKEN"},
Destination: &hetznerCloudAPIToken,
},
},
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
var sshKeysRaw []string
var sshKeys []*hcloud.SSHKey
for _, sshKey := range c.StringSlice("hetzner-ssh-keys") {
if sshKey == "" {
continue
}
if hetznerCloudAPIToken == "" {
logrus.Fatal("Hetzner Cloud API token is missing")
}
ctx := context.Background()
client := hcloud.NewClient(hcloud.WithToken(hetznerCloudAPIToken))
logrus.Debugf("successfully created hetzner cloud API client")
var sshKeys []*hcloud.SSHKey
for _, sshKey := range c.StringSlice("ssh-keys") {
sshKey, _, err := client.SSHKey.GetByName(ctx, sshKey)
if err != nil {
logrus.Fatal(err)
}
sshKeys = append(sshKeys, sshKey)
}
serverOpts := hcloud.ServerCreateOpts{
Name: name,
ServerType: &hcloud.ServerType{Name: hetznerCloudType},
Image: &hcloud.Image{Name: hetznerCloudImage},
SSHKeys: sshKeys,
Location: &hcloud.Location{Name: hetznerCloudLocation},
}
res, _, err := client.Server.Create(ctx, serverOpts)
sshKey, _, err := client.SSHKey.GetByName(c.Context, sshKey)
if err != nil {
logrus.Fatal(err)
return err
}
sshKeys = append(sshKeys, sshKey)
sshKeysRaw = append(sshKeysRaw, sshKey.Name)
}
logrus.Debugf("new server '%s' created", name)
serverOpts := hcloud.ServerCreateOpts{
Name: internal.HetznerCloudName,
ServerType: &hcloud.ServerType{Name: internal.HetznerCloudType},
Image: &hcloud.Image{Name: internal.HetznerCloudImage},
SSHKeys: sshKeys,
Location: &hcloud.Location{Name: internal.HetznerCloudLocation},
}
tableColumns := []string{"Name", "IPv4", "Root Password"}
table := formatter.CreateTable(tableColumns)
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
internal.HetznerCloudName,
internal.HetznerCloudType,
internal.HetznerCloudImage,
strings.Join(sshKeysRaw, "\n"),
internal.HetznerCloudLocation,
})
table.Render()
if len(sshKeys) > 0 {
table.Append([]string{name, res.Server.PublicNet.IPv4.IP.String(), "N/A (using SSH keys)"})
} else {
table.Append([]string{name, res.Server.PublicNet.IPv4.IP.String(), res.RootPassword})
}
response := false
prompt := &survey.Confirm{
Message: "continue with hetzner cloud VPS creation?",
}
table.Render()
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
return nil
},
if !response {
logrus.Fatal("exiting as requested")
}
res, _, err := client.Server.Create(c.Context, serverOpts)
if err != nil {
return err
}
var rootPassword string
if len(sshKeys) > 0 {
rootPassword = "N/A (using SSH keys)"
} else {
rootPassword = res.RootPassword
}
ip := res.Server.PublicNet.IPv4.IP.String()
fmt.Println(fmt.Sprintf(`
Your new Hetzner Cloud VPS has successfully been created! Here are the details:
name: %s
IP address: %s
root password: %s
You can access this new VPS via SSH using the following command:
ssh root@%s
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
not list this server)! You will need to assign a domain name record ("abra
record new") and add the server to your Abra configuration ("abra server add")
to have a working server that you can deploy Co-op Cloud apps to.
`,
internal.HetznerCloudName, ip, rootPassword,
ip,
))
return nil
}
var capsulInstance string
var capsulType string
var capsulImage string
var capsulSSHKey string
var capsulAPIToken string
var serverNewCapsulCommand = &cli.Command{
Name: "capsul",
Usage: "Create a new Capsul virtual server",
ArgsUsage: "<name>",
Description: `
Create a new Capsul virtual server.
func newCapsulVPS(c *cli.Context) error {
if err := internal.EnsureNewCapsulVPSFlags(c); err != nil {
return err
}
This command uses the uses the Capsul API bindings of your chosen instance to
send a server creation request. You must already have an account on your chosen
Capsul instance before using this command.
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", internal.CapsulInstanceURL)
Your token can be loaded from the environment using the CAPSUL_TOKEN
environment variable or otherwise passing the "--env/-e" flag.
`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "instance",
Aliases: []string{"I"},
Usage: "Capsul instance",
Destination: &capsulInstance,
Value: "yolo.servers.coop",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Server type",
Value: "f1-xs",
Destination: &capsulType,
},
&cli.StringFlag{
Name: "image",
Aliases: []string{"i"},
Usage: "Image type",
Value: "debian10",
Destination: &capsulImage,
},
&cli.StringFlag{
Name: "ssh-key",
Aliases: []string{"s"},
Usage: "SSH key",
Value: "",
Destination: &capsulSSHKey,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"T"},
Usage: "Capsul instance API token",
EnvVars: []string{"CAPSUL_TOKEN"},
Destination: &capsulAPIToken,
},
},
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no name provided"))
var sshKeys []string
for _, sshKey := range c.StringSlice("capsul-ssh-keys") {
if sshKey == "" {
continue
}
sshKeys = append(sshKeys, sshKey)
}
if capsulAPIToken == "" {
logrus.Fatal("Capsul API token is missing")
}
tableColumns := []string{"instance", "name", "type", "image", "ssh-keys"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
internal.CapsulInstanceURL,
internal.CapsulName,
internal.CapsulType,
internal.CapsulImage,
strings.Join(sshKeys, "\n"),
})
table.Render()
// yep, the response time is quite slow, something to fix on the Capsul side
client := &http.Client{Timeout: 20 * time.Second}
response := false
prompt := &survey.Confirm{
Message: "continue with capsul creation?",
}
capsulCreateURL := fmt.Sprintf("https://%s/api/capsul/create", capsulInstance)
logrus.Debugf("using '%s' as capsul create url", capsulCreateURL)
values := map[string]string{
"name": name,
"size": capsulType,
"os": capsulImage,
"ssh_key_0": capsulSSHKey,
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
payload, err := json.Marshal(values)
if err != nil {
logrus.Fatal(err)
}
if !response {
logrus.Fatal("exiting as requested")
}
req, err := http.NewRequest("POST", capsulCreateURL, bytes.NewBuffer(payload))
if err != nil {
logrus.Fatal(err)
}
capsulClient := libcapsul.New(capsulCreateURL, internal.CapsulAPIToken)
resp, err := capsulClient.Create(
internal.CapsulName,
internal.CapsulType,
internal.CapsulImage,
sshKeys,
)
if err != nil {
return err
}
req.Header = http.Header{
"Content-Type": []string{"application/json"},
"Authorization": []string{capsulAPIToken},
}
fmt.Println(fmt.Sprintf(`
Your new Capsul has successfully been created! Here are the details:
res, err := client.Do(req)
if err != nil {
logrus.Fatal(err)
}
defer res.Body.Close()
Capsul name: %s
Capsul ID: %v
if res.StatusCode != http.StatusOK {
body, err := ioutil.ReadAll(res.Body)
if err != nil {
panic(err)
}
logrus.Fatal(string(body))
}
You will need to log into your Capsul instance web interface to retrieve the IP
address. You can learn all about how to get SSH access to your new Capsul on:
type capsulCreateResponse struct{ ID string }
var resp capsulCreateResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("capsul created with ID: '%s'", resp.ID)
%s/about-ssh
tableColumns := []string{"Name", "ID"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{name, resp.ID})
table.Render()
Please note, this server is not managed by Abra yet (i.e. "abra server ls" will
not list this server)! You will need to assign a domain name record ("abra
record new") and add the server to your Abra configuration ("abra server add")
to have a working server that you can deploy Co-op Cloud apps to.
`, internal.CapsulName, resp.ID, internal.CapsulInstanceURL))
return nil
},
return nil
}
var serverNewCommand = &cli.Command{
@ -253,12 +179,58 @@ var serverNewCommand = &cli.Command{
Aliases: []string{"n"},
Usage: "Create a new server using a 3rd party provider",
Description: `
Use a provider plugin to create a new server which can then be used to house a
new Co-op Cloud installation.
This command creates a new server via a 3rd party provider.
The following providers are supported:
Capsul https://git.cyberia.club/Cyberia/capsul-flask
Hetzner Cloud https://docs.hetzner.com/cloud
You may invoke this command in "wizard" mode and be prompted for input:
abra record new
API tokens are read from the environment if specified, e.g.
export HCLOUD_TOKEN=...
Where "$provider_TOKEN" is the expected env var format.
`,
ArgsUsage: "<provider>",
Subcommands: []*cli.Command{
serverNewHetznerCloudCommand,
serverNewCapsulCommand,
Flags: []cli.Flag{
internal.ServerProviderFlag,
// Capsul
internal.CapsulInstanceURLFlag,
internal.CapsulTypeFlag,
internal.CapsulImageFlag,
internal.CapsulSSHKeysFlag,
internal.CapsulAPITokenFlag,
// Hetzner
internal.HetznerCloudNameFlag,
internal.HetznerCloudTypeFlag,
internal.HetznerCloudImageFlag,
internal.HetznerCloudSSHKeysFlag,
internal.HetznerCloudLocationFlag,
internal.HetznerCloudAPITokenFlag,
},
Action: func(c *cli.Context) error {
if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err)
}
switch internal.ServerProvider {
case "capsul":
if err := newCapsulVPS(c); err != nil {
logrus.Fatal(err)
}
case "hetzner-cloud":
if err := newHetznerCloudVPS(c); err != nil {
logrus.Fatal(err)
}
}
return nil
},
}

View File

@ -1,29 +1,158 @@
package server
import (
"fmt"
"os"
"path/filepath"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var serverRemoveCommand = &cli.Command{
Name: "remove",
Aliases: []string{"rm"},
Usage: "Remove a server",
Description: `
This does not destroy the actual server. It simply removes it from Abra
internal bookkeeping so that it is not managed any more.
`,
HideHelp: true,
Action: func(c *cli.Context) error {
domainName := internal.ValidateDomain(c)
var rmServer bool
var rmServerFlag = &cli.BoolFlag{
Name: "server",
Aliases: []string{"s"},
Value: false,
Usage: "remove the actual server also",
Destination: &rmServer,
}
if err := client.DeleteContext(domainName); err != nil {
func rmHetznerCloudVPS(c *cli.Context) error {
if internal.HetznerCloudName == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &internal.HetznerCloudName); err != nil {
return err
}
}
if internal.HetznerCloudAPIToken == "" && !internal.NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &internal.HetznerCloudAPIToken); err != nil {
return err
}
} else {
internal.HetznerCloudAPIToken = token
}
}
client := hcloud.NewClient(hcloud.WithToken(internal.HetznerCloudAPIToken))
server, _, err := client.Server.Get(c.Context, internal.HetznerCloudName)
if err != nil {
return err
}
if server == nil {
logrus.Fatalf("library provider reports that %s doesn't exist?", internal.HetznerCloudName)
}
fmt.Println(fmt.Sprintf(`
You have requested that Abra delete the following server (%s). Please be
absolutely sure that this is indeed the server that you would like to have
removed. There will be no going back once you confirm, the server will be
destroyed.
`, server.Name))
tableColumns := []string{"name", "type", "image", "location"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
server.Name,
server.ServerType.Name,
server.Image.Name,
server.Datacenter.Name,
})
table.Render()
response := false
prompt := &survey.Confirm{
Message: "continue with hetzner cloud VPS removal?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
_, err = client.Server.Delete(c.Context, server)
if err != nil {
return err
}
logrus.Infof("%s has been deleted from your hetzner cloud account", internal.HetznerCloudName)
return nil
}
var serverRemoveCommand = &cli.Command{
Name: "remove",
Aliases: []string{"rm"},
ArgsUsage: "<server>",
Usage: "Remove a managed server",
Description: `
This command removes a server from Abra management.
Depending on whether you used a 3rd party provider to create this server ("abra
server new"), you can also destroy the virtual server as well. Pass
"--server/-s" to tell Abra to try to delete this VPS.
Otherwise, Abra will remove the internal bookkeeping (~/.abra/servers/...) and
underlying client connection context. This server will then be lost in time,
like tears in rain.
`,
Flags: []cli.Flag{
rmServerFlag,
// Hetzner
internal.HetznerCloudNameFlag,
internal.HetznerCloudAPITokenFlag,
},
Action: func(c *cli.Context) error {
serverName, err := internal.ValidateServer(c)
if err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at '%s' has been forgotten", domainName)
if rmServer {
if err := internal.EnsureServerProvider(); err != nil {
logrus.Fatal(err)
}
switch internal.ServerProvider {
case "capsul":
logrus.Warn("capsul provider does not support automatic removal yet, sorry!")
case "hetzner-cloud":
if err := rmHetznerCloudVPS(c); err != nil {
logrus.Fatal(err)
}
}
}
if err := client.DeleteContext(serverName); err != nil {
logrus.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, serverName)); err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at '%s' has been lost in time, like tears in rain", serverName)
return nil
},

View File

@ -8,17 +8,18 @@ import (
var ServerCommand = &cli.Command{
Name: "server",
Aliases: []string{"s"},
Usage: "Manage servers",
Usage: "Manage servers via 3rd party providers",
Description: `
Manage the lifecycle of a server.
These commands support creating, managing and removing servers using 3rd party
integrations.
These commands support creating new servers using 3rd party integrations,
initialising existing servers to support Co-op Cloud deployments and managing
the connections to those servers.
Servers can be created from scratch using the "abra server new" command. If you
already have a server, you can add it to your configuration using "abra server
add". Abra can provision servers so that they are ready to deploy Co-op Cloud
apps, see available flags on "server add" for more.
`,
Subcommands: []*cli.Command{
serverNewCommand,
serverInitCommand,
serverAddCommand,
serverListCommand,
serverRemoveCommand,

View File

@ -8,12 +8,24 @@ import (
"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)

View File

@ -1,15 +0,0 @@
package cli
import (
"github.com/urfave/cli/v2"
)
// VersionCommand prints the version of abra.
var VersionCommand = &cli.Command{
Name: "version",
Usage: "Print version",
Action: func(c *cli.Context) error {
cli.VersionPrinter(c)
return nil
},
}

80
go.mod
View File

@ -1,19 +1,19 @@
module coopcloud.tech/abra
go 1.17
go 1.16
require (
coopcloud.tech/tagcmp v0.0.0-20211011140827-4f27c74467eb
github.com/AlecAivazis/survey/v2 v2.3.1
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
github.com/docker/cli v20.10.8+incompatible
github.com/docker/cli v20.10.11+incompatible
github.com/docker/distribution v2.7.1+incompatible
github.com/docker/docker v20.10.8+incompatible
github.com/docker/docker v20.10.11+incompatible
github.com/docker/go-units v0.4.0
github.com/go-git/go-git/v5 v5.4.2
github.com/hetznercloud/hcloud-go v1.32.0
github.com/moby/sys/signal v0.5.0
github.com/hetznercloud/hcloud-go v1.33.1
github.com/moby/sys/signal v0.6.0
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1
@ -25,75 +25,23 @@ require (
)
require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.4.17 // indirect
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
github.com/Microsoft/hcsshim v0.8.21 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
github.com/acomagu/bufpipe v1.0.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/containerd/cgroups v1.0.1 // indirect
github.com/containerd/containerd v1.5.5 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/fvbommel/sortorder v1.0.2 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.3.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/google/go-cmp v0.5.5 // indirect
github.com/gliderlabs/ssh v0.3.3
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.0.3 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/kevinburke/ssh_config v1.1.0
github.com/libdns/gandi v1.0.2
github.com/libdns/libdns v0.2.1
github.com/moby/sys/mount v0.2.0 // indirect
github.com/moby/sys/mountinfo v0.4.1 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.1 // indirect
github.com/opencontainers/runc v1.0.2 // indirect
github.com/prometheus/client_golang v1.11.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spf13/cobra v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opencensus.io v0.22.3 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
golang.org/x/text v0.3.4 // indirect
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a // indirect
google.golang.org/grpc v1.33.2 // indirect
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359
)

46
go.sum
View File

@ -21,13 +21,13 @@ cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIA
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
coopcloud.tech/tagcmp v0.0.0-20211007122926-aa7b4ff851f6 h1:EV1aomKx6n0jLZ847ejeqroE4K9E+yX5NvEyQufkZVk=
coopcloud.tech/tagcmp v0.0.0-20211007122926-aa7b4ff851f6/go.mod h1:ESVm0wQKcbcFi06jItF3rI7enf4Jt2PvbkWpDDHk1DQ=
coopcloud.tech/tagcmp v0.0.0-20211011140827-4f27c74467eb h1:Jf+Dnna2kXcNQvcA5JMp6d2Uyvg2pIVJfip9+X5FrH0=
coopcloud.tech/tagcmp v0.0.0-20211011140827-4f27c74467eb/go.mod h1:ESVm0wQKcbcFi06jItF3rI7enf4Jt2PvbkWpDDHk1DQ=
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e h1:o5OZInc5b9esiN4hlfjZY6u0r+qB2iSv/11jnMGuR38=
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e/go.mod h1:HEQ9pSJRsDKabMxPfYCCzpVpAreLoC4Gh4SkVyOaKvk=
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52 h1:cyFFOl0tKe+dVHt8saejG8xoff33eQiHxFCVzRpPUjM=
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52/go.mod h1:ESVm0wQKcbcFi06jItF3rI7enf4Jt2PvbkWpDDHk1DQ=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.3.1 h1:lzkuHA60pER7L4eYL8qQJor4bUWlJe4V0gqAT19tdOA=
github.com/AlecAivazis/survey/v2 v2.3.1/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8=
github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4 h1:aYUdiI42a4fWfPoUr25XlaJrFEICv24+o/gWhqYS/jk=
github.com/Autonomic-Cooperative/godotenv v1.3.1-0.20210731170023-c37c0920d1a4/go.mod h1:oZRCMMRS318l07ei4DTqbZoOawfJlJ4yyo8juk2v4Rk=
github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
@ -88,8 +88,9 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
@ -259,14 +260,14 @@ github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/docker/cli v20.10.8+incompatible h1:/zO/6y9IOpcehE49yMRTV9ea0nBpb8OeqSskXLNfH1E=
github.com/docker/cli v20.10.8+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v20.10.11+incompatible h1:tXU1ezXcruZQRrMP8RN2z9N91h+6egZTS1gsPsKantc=
github.com/docker/cli v20.10.11+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v20.10.8+incompatible h1:RVqD337BgQicVCzYrrlhLDWhq6OAD2PJDUg2LsEUvKM=
github.com/docker/docker v20.10.8+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo=
github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.6.4 h1:axCks+yV+2MR3/kZhAmy07yC56WZ2Pwu/fKWtKuZB0o=
github.com/docker/docker-credential-helpers v0.6.4/go.mod h1:ofX3UI0Gz1TteYBjtgs07O36Pyasyp66D2uKT7H8W1c=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
@ -314,8 +315,9 @@ github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui72
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/gliderlabs/ssh v0.3.3 h1:mBQ8NiOgDkINJrZtoizkC3nDNYgSaWtxyem6S2XHBtA=
github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
@ -448,8 +450,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hetznercloud/hcloud-go v1.32.0 h1:7zyN2V7hMlhm3HZdxOarmOtvzKvkcYKjM0hcwYMQZz0=
github.com/hetznercloud/hcloud-go v1.32.0/go.mod h1:XX/TQub3ge0yWR2yHWmnDVIrB+MQbda1pHxkUmDlUME=
github.com/hetznercloud/hcloud-go v1.33.1 h1:W1HdO2bRLTKU4WsyqAasDSpt54fYO4WNckWYfH5AuCQ=
github.com/hetznercloud/hcloud-go v1.33.1/go.mod h1:XX/TQub3ge0yWR2yHWmnDVIrB+MQbda1pHxkUmDlUME=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -488,8 +490,9 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.1.0 h1:pH/t1WS9NzT8go394IqZeJTMHVm6Cr6ZJ6AQ+mdNo/o=
github.com/kevinburke/ssh_config v1.1.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@ -512,6 +515,11 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/libdns/gandi v1.0.2 h1:1Ts8UpI1x5PVKpOjKC7Dn4+EObndz9gm6vdZnloHSKQ=
github.com/libdns/gandi v1.0.2/go.mod h1:hxpbQKcQFgQrTS5lV4tAgn6QoL6HcCnoBJaW5nOW4Sk=
github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@ -557,8 +565,8 @@ github.com/moby/sys/mount v0.2.0/go.mod h1:aAivFE2LB3W4bACsUXChRHQ0qKWsetY4Y9V7s
github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
github.com/moby/sys/mountinfo v0.4.1 h1:1O+1cHA1aujwEwwVMa2Xm2l+gIpUHyd3+D+d7LZh1kM=
github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
github.com/moby/sys/signal v0.5.0 h1:MzpEFrMxugDynb1gkTIThU1O3wEmrAkOY+G9dHcHnCc=
github.com/moby/sys/signal v0.5.0/go.mod h1:JwObcMnOrUy2VTP5swPKWwywH0Mbgk8Y5qua9iwtIRM=
github.com/moby/sys/signal v0.6.0 h1:aDpY94H8VlhTGa9sNYUFCFsMZIUh5wm0B6XkIoJj/iY=
github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg=
github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
@ -814,6 +822,7 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -966,8 +975,9 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 h1:xrCZDmdtoloIiooiA9q0OQb9r8HejIHYoHGhGCe1pGg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=

View File

@ -5,8 +5,8 @@ import (
"fmt"
"strings"
"coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/stack"
apiclient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)

View File

@ -54,15 +54,15 @@ type tag = string
// service represents a service within a recipe.
type service = string
// serviceMeta represents meta info associated with a service.
type serviceMeta struct {
// 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
type RecipeVersions []map[tag]map[service]ServiceMeta
// RecipeMeta represents metadata for a recipe in the abra catalogue.
type RecipeMeta struct {
@ -405,7 +405,6 @@ func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Keep: false,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
@ -420,7 +419,7 @@ func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
return err
}
versionMeta := make(map[string]serviceMeta)
versionMeta := make(map[string]ServiceMeta)
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
@ -438,7 +437,7 @@ func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
return err
}
versionMeta[service.Name] = serviceMeta{
versionMeta[service.Name] = ServiceMeta{
Digest: digest,
Image: path,
Tag: img.(reference.NamedTagged).Tag(),
@ -447,7 +446,7 @@ func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
logrus.Debugf("collecting digest: '%s', image: '%s', tag: '%s'", digest, path, tag)
}
versions = append(versions, map[string]map[string]serviceMeta{tag: versionMeta})
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil
}); err != nil {
@ -467,7 +466,6 @@ func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Keep: false,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {

View File

@ -4,37 +4,44 @@ package client
import (
"net/http"
"os"
"time"
contextPkg "coopcloud.tech/abra/pkg/context"
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// New initiates a new Docker client.
func New(contextName string) (*client.Client, error) {
context, err := GetContext(contextName)
if err != nil {
return nil, err
}
ctxEndpoint, err := GetContextEndpoint(context)
if err != nil {
return nil, err
}
helper := newConnectionHelper(ctxEndpoint)
httpClient := &http.Client{
// No tls, no proxy
Transport: &http.Transport{
DialContext: helper.Dialer,
},
}
var clientOpts []client.Opt
clientOpts = append(clientOpts,
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
)
if contextName != "default" {
context, err := GetContext(contextName)
if err != nil {
return nil, err
}
ctxEndpoint, err := contextPkg.GetContextEndpoint(context)
if err != nil {
return nil, err
}
helper := commandconnPkg.NewConnectionHelper(ctxEndpoint)
httpClient := &http.Client{
// No tls, no proxy
Transport: &http.Transport{
DialContext: helper.Dialer,
IdleConnTimeout: 30 * time.Second,
},
}
clientOpts = append(clientOpts,
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
)
}
version := os.Getenv("DOCKER_API_VERSION")
if version != "" {
@ -45,7 +52,7 @@ func New(contextName string) (*client.Client, error) {
cl, err := client.NewClientWithOpts(clientOpts...)
if err != nil {
logrus.Fatalf("unable to create Docker client: %s", err)
return nil, err
}
logrus.Debugf("created client for '%s'", contextName)

View File

@ -1,45 +0,0 @@
package client
import (
"github.com/docker/cli/cli/connhelper"
"github.com/docker/cli/cli/context/docker"
dCliContextStore "github.com/docker/cli/cli/context/store"
dClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
func newConnectionHelper(daemonURL string) *connhelper.ConnectionHelper {
helper, err := connhelper.GetConnectionHelper(daemonURL)
if err != nil {
logrus.Fatal(err)
}
return helper
}
func getDockerEndpoint(host string) (docker.Endpoint, error) {
skipTLSVerify := false
ep := docker.Endpoint{
EndpointMeta: docker.EndpointMeta{
Host: host,
SkipTLSVerify: skipTLSVerify,
},
}
// try to resolve a docker client, validating the configuration
opts, err := ep.ClientOpts()
if err != nil {
return docker.Endpoint{}, err
}
if _, err := dClient.NewClientWithOpts(opts...); err != nil {
return docker.Endpoint{}, err
}
return ep, nil
}
func getDockerEndpointMetadataAndTLS(host string) (docker.EndpointMeta, *dCliContextStore.EndpointTLSData, error) {
ep, err := getDockerEndpoint(host)
if err != nil {
return docker.EndpointMeta{}, nil, err
}
return ep.EndpointMeta, ep.TLSData.ToStoreTLSData(), nil
}

View File

@ -1,191 +0,0 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2013-2017 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,7 +0,0 @@
# github.com/docker/cli/cli/command/container
Due to this literally just being copy-pasted from the lib, the Apache license
will be posted in this folder. Small edits to the source code have been to
function names and parts we don't need deleted.
Same vibe as [../convert](../convert).

View File

@ -4,12 +4,11 @@ import (
"errors"
"fmt"
command "github.com/docker/cli/cli/command"
"coopcloud.tech/abra/pkg/context"
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
dConfig "github.com/docker/cli/cli/config"
context "github.com/docker/cli/cli/context"
"github.com/docker/cli/cli/context/docker"
contextStore "github.com/docker/cli/cli/context/store"
"github.com/moby/term"
"github.com/sirupsen/logrus"
)
@ -33,7 +32,7 @@ func CreateContext(contextName string, user string, port string) error {
// createContext interacts with Docker Context to create a Docker context config
func createContext(name string, host string) error {
s := NewDefaultDockerContextStore()
s := context.NewDefaultDockerContextStore()
contextMetadata := contextStore.Metadata{
Endpoints: make(map[string]interface{}),
Name: name,
@ -43,7 +42,7 @@ func createContext(name string, host string) error {
Endpoints: make(map[string]contextStore.EndpointTLSData),
}
dockerEP, dockerTLS, err := getDockerEndpointMetadataAndTLS(host)
dockerEP, dockerTLS, err := commandconnPkg.GetDockerEndpointMetadataAndTLS(host)
if err != nil {
return err
}
@ -81,48 +80,14 @@ func DeleteContext(name string) error {
return err
}
return NewDefaultDockerContextStore().Remove(name)
return context.NewDefaultDockerContextStore().Remove(name)
}
func GetContext(contextName string) (contextStore.Metadata, error) {
ctx, err := NewDefaultDockerContextStore().GetMetadata(contextName)
ctx, err := context.NewDefaultDockerContextStore().GetMetadata(contextName)
if err != nil {
return contextStore.Metadata{}, err
}
return ctx, nil
}
func GetContextEndpoint(ctx contextStore.Metadata) (string, error) {
// safe to use docker key hardcoded since abra doesn't use k8s... yet...
endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase)
if !ok {
err := errors.New("context lacks Docker endpoint")
return "", err
}
return endpointmeta.Host, nil
}
func newContextStore(dir string, config contextStore.Config) contextStore.Store {
return contextStore.New(dir, config)
}
func NewDefaultDockerContextStore() *command.ContextStoreWithDefault {
// Grabbing the stderr from Docker commands
// Much easier to fit this into the code we are using to replicate docker cli commands
_, _, stderr := term.StdStreams()
// TODO: Look into custom docker configs in case users want that
dockerConfig := dConfig.LoadDefaultConfigFile(stderr)
contextDir := dConfig.ContextStoreDir()
storeConfig := command.DefaultContextStoreConfig()
store := newContextStore(contextDir, storeConfig)
dockerContextStore := &command.ContextStoreWithDefault{
Store: store,
Resolver: func() (*command.DefaultContext, error) {
// nil for the Opts because it works without it and its a cli thing
return command.ResolveDefaultContext(nil, dockerConfig, storeConfig, stderr)
},
}
return dockerContextStore
}

View File

@ -1,52 +0,0 @@
package client_test
import (
"testing"
"coopcloud.tech/abra/pkg/client"
dContext "github.com/docker/cli/cli/context"
dCliContextStore "github.com/docker/cli/cli/context/store"
)
type TestContext struct {
context dCliContextStore.Metadata
expected_endpoint string
}
func dockerContext(host, key string) TestContext {
dockerContext := dCliContextStore.Metadata{
Name: "foo",
Metadata: nil,
Endpoints: map[string]interface{}{
key: dContext.EndpointMetaBase{
Host: host,
SkipTLSVerify: false,
},
},
}
return TestContext{
context: dockerContext,
expected_endpoint: host,
}
}
func TestGetContextEndpoint(t *testing.T) {
var testDockerContexts = []TestContext{
dockerContext("ssh://foobar", "docker"),
dockerContext("ssh://foobar", "k8"),
}
for _, context := range testDockerContexts {
endpoint, err := client.GetContextEndpoint(context.context)
if err != nil {
if err.Error() != "context lacks Docker endpoint" {
t.Error(err)
}
} else {
if endpoint != context.expected_endpoint {
t.Errorf("did not get correct context endpoint. Expected: %s, received: %s", context.expected_endpoint, endpoint)
}
}
}
}

View File

@ -1,191 +0,0 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2013-2017 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,10 +0,0 @@
# github.com/docker/cli/cli/compose/convert
DISCLAIMER: This is like the entire `github.com/docker/cli/cli/compose/convert`
package. This should be an easy import but importing it creates DEPENDENCY
HELL. I tried for an hour to fix it but it would work. TRY TO FIX AT YOUR OWN
RISK!!!
Due to this literally just being copy-pasted from the lib, the Apache license
will be posted in this folder. Small edits to the source code have been to
function names and parts we don't need deleted.

View File

@ -7,9 +7,9 @@ import (
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/client/stack"
loader "coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
@ -117,8 +117,11 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
continue
}
discovered := false
for oldLabel, value := range service.Deploy.Labels {
if strings.HasPrefix(oldLabel, "coop-cloud") {
discovered = true
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return err
@ -127,13 +130,19 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
replacedBytes := strings.Replace(string(bytes), old, label, -1)
logrus.Debugf("updating '%s' to '%s' in '%s'", old, label, compose.Filename)
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil {
return err
}
}
}
if !discovered {
logrus.Warn("no existing label found, cannot continue...")
logrus.Fatalf("add '%s' manually, automagic insertion not supported yet", label)
}
}
return nil

View File

@ -9,9 +9,9 @@ import (
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/client/convert"
loader "coopcloud.tech/abra/pkg/client/stack"
stack "coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/upstream/convert"
loader "coopcloud.tech/abra/pkg/upstream/stack"
stack "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/sirupsen/logrus"
)

44
pkg/context/context.go Normal file
View File

@ -0,0 +1,44 @@
package context
import (
"errors"
"github.com/docker/cli/cli/command"
dConfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/context"
contextStore "github.com/docker/cli/cli/context/store"
cliflags "github.com/docker/cli/cli/flags"
"github.com/moby/term"
)
func NewDefaultDockerContextStore() *command.ContextStoreWithDefault {
_, _, stderr := term.StdStreams()
dockerConfig := dConfig.LoadDefaultConfigFile(stderr)
contextDir := dConfig.ContextStoreDir()
storeConfig := command.DefaultContextStoreConfig()
store := newContextStore(contextDir, storeConfig)
opts := &cliflags.CommonOptions{Context: "default"}
dockerContextStore := &command.ContextStoreWithDefault{
Store: store,
Resolver: func() (*command.DefaultContext, error) {
return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr)
},
}
return dockerContextStore
}
func GetContextEndpoint(ctx contextStore.Metadata) (string, error) {
endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase)
if !ok {
err := errors.New("context lacks Docker endpoint")
return "", err
}
return endpointmeta.Host, nil
}
func newContextStore(dir string, config contextStore.Config) contextStore.Store {
return contextStore.New(dir, config)
}

96
pkg/dns/common.go Normal file
View File

@ -0,0 +1,96 @@
package dns
import (
"context"
"fmt"
"net"
"os"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
)
// NewToken constructs a new DNS provider token.
func NewToken(provider, providerTokenEnvVar string) (string, error) {
if token, present := os.LookupEnv(providerTokenEnvVar); present {
return token, nil
}
logrus.Debugf("no %s in environment, asking via stdin", providerTokenEnvVar)
var token string
prompt := &survey.Input{
Message: fmt.Sprintf("%s API token?", provider),
}
if err := survey.AskOne(prompt, &token); err != nil {
return "", err
}
return token, nil
}
// EnsureIPv4 ensures that an ipv4 address is set for a domain name
func EnsureIPv4(domainName string) (string, error) {
var ipv4 string
// comrade librehosters DNS resolver -> https://www.privacy-handbuch.de/handbuch_93d.htm
freifunkDNS := "5.1.66.255:53"
resolver := &net.Resolver{
PreferGo: false,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
return d.DialContext(ctx, "udp", freifunkDNS)
},
}
logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
ctx := context.Background()
ips, err := resolver.LookupIPAddr(ctx, domainName)
if err != nil {
return ipv4, err
}
if len(ips) == 0 {
return ipv4, fmt.Errorf("unable to retrieve ipv4 address for %s", domainName)
}
ipv4 = ips[0].IP.To4().String()
logrus.Debugf("discovered the following ipv4 addr: %s", ipv4)
return ipv4, nil
}
// EnsureDomainsResolveSameIPv4 ensures that domains resolve to the same ipv4 address
func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
var ipv4 string
domainIPv4, err := EnsureIPv4(domainName)
if err != nil {
return ipv4, err
}
if domainIPv4 == "" {
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", domainName)
}
serverIPv4, err := EnsureIPv4(server)
if err != nil {
return ipv4, err
}
if serverIPv4 == "" {
return ipv4, fmt.Errorf("cannot resolve ipv4 for %s?", server)
}
if domainIPv4 != serverIPv4 {
err := "app domain %s (%s) does not appear to resolve to app server %s (%s)?"
return ipv4, fmt.Errorf(err, domainName, domainIPv4, server, serverIPv4)
}
return ipv4, nil
}

15
pkg/dns/gandi/gandi.go Normal file
View File

@ -0,0 +1,15 @@
package gandi
import (
"coopcloud.tech/abra/pkg/dns"
"github.com/libdns/gandi"
)
// New constructs a new DNs provider.
func New() (gandi.Provider, error) {
token, err := dns.NewToken("Gandi", "GANDI_TOKEN")
if err != nil {
return gandi.Provider{}, err
}
return gandi.Provider{APIToken: token}, nil
}

View File

@ -3,6 +3,7 @@ package git
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/go-git/go-git/v5"
@ -24,6 +25,10 @@ func Clone(dir, url string) error {
ReferenceName: plumbing.ReferenceName("refs/heads/main"),
})
if err != nil {
if strings.Contains(err.Error(), "authentication required") {
name := filepath.Base(dir)
return fmt.Errorf("unable to clone %s, does %s exist?", name, url)
}
return err
}
}
@ -62,7 +67,6 @@ func EnsureUpToDate(dir string) error {
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Keep: false,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {

14
pkg/git/common.go Normal file
View File

@ -0,0 +1,14 @@
package git
import (
"fmt"
"os"
)
// EnsureGitRepo ensures a git repo .git folder exists
func EnsureGitRepo(repoPath string) error {
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
return fmt.Errorf("no .git directory in %s?", repoPath)
}
return nil
}

38
pkg/git/init.go Normal file
View File

@ -0,0 +1,38 @@
package git
import (
"github.com/go-git/go-git/v5"
gitPkg "github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// Init inits a new repo and commits all the stuff if you want
func Init(repoPath string, commit bool) error {
if _, err := gitPkg.PlainInit(repoPath, false); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("initialised new git repo in %s", repoPath)
if commit {
commitRepo, err := git.PlainOpen(repoPath)
if err != nil {
logrus.Fatal(err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
logrus.Fatal(err)
}
if err := commitWorktree.AddGlob("**"); err != nil {
return err
}
if _, err = commitWorktree.Commit("init", &git.CommitOptions{}); err != nil {
return err
}
logrus.Debugf("init committed all files for new git repo in %s", repoPath)
}
return nil
}

55
pkg/git/read.go Normal file
View File

@ -0,0 +1,55 @@
package git
import (
"path"
"coopcloud.tech/abra/pkg/config"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
// GetRecipeHead retrieves latest HEAD metadata.
func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return nil, err
}
head, err := repo.Head()
if err != nil {
return nil, err
}
return head, nil
}
// 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)
if err != nil {
return false, err
}
worktree, err := repo.Worktree()
if err != nil {
return false, err
}
status, err := worktree.Status()
if err != nil {
return false, err
}
if status.String() != "" {
logrus.Debugf("discovered git status for %s repository: %s", recipeName, status.String())
} else {
logrus.Debugf("discovered clean git status for %s repository", recipeName)
}
return status.IsClean(), nil
}

20
pkg/limit/limit.go Normal file
View File

@ -0,0 +1,20 @@
package limit // https://github.com/tidwall/limiter
// Limiter is for limiting the number of concurrent operations. This
type Limiter struct{ sem chan struct{} }
// New returns a new Limiter. The limit param is the maximum number of
// concurrent operations.
func New(limit int) *Limiter {
return &Limiter{make(chan struct{}, limit)}
}
// Begin an operation.
func (l *Limiter) Begin() {
l.sem <- struct{}{}
}
// End the operation.
func (l *Limiter) End() {
<-l.sem
}

View File

@ -2,15 +2,16 @@ package recipe
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/client/stack"
loader "coopcloud.tech/abra/pkg/client/stack"
"coopcloud.tech/abra/pkg/compose"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
@ -95,13 +96,22 @@ func Get(recipeName string) (Recipe, error) {
return Recipe{Name: recipeName, Config: config}, nil
}
// EnsureExists checks whether a recipe has been cloned locally or not.
// EnsureExists ensures that a recipe is locally cloned
func EnsureExists(recipe string) error {
recipeDir := path.Join(config.ABRA_DIR, "apps", strings.ToLower(recipe))
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe)
if err := gitPkg.Clone(recipeDir, url); err != nil {
if _, err := os.Stat(recipeDir); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, attemmpting to clone", recipeDir)
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe)
if err := gitPkg.Clone(recipeDir, url); err != nil {
return err
}
}
if err := gitPkg.EnsureGitRepo(recipeDir); err != nil {
return err
}
return nil
}
@ -109,6 +119,19 @@ func EnsureExists(recipe string) error {
func EnsureVersion(recipeName, version string) error {
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
isClean, err := gitPkg.IsClean(recipeName)
if err != nil {
return err
}
if !isClean {
return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
}
if err := gitPkg.EnsureGitRepo(recipeDir); err != nil {
return err
}
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return err
@ -119,10 +142,10 @@ func EnsureVersion(recipeName, version string) error {
return nil
}
logrus.Debugf("read '%s' as tags for recipe '%s'", tags, recipeName)
var parsedTags []string
var tagRef plumbing.ReferenceName
if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
parsedTags = append(parsedTags, ref.Name().Short())
if ref.Name().Short() == version {
tagRef = ref.Name()
}
@ -131,6 +154,8 @@ func EnsureVersion(recipeName, version string) error {
return err
}
logrus.Debugf("read '%s' as tags for recipe '%s'", strings.Join(parsedTags, ", "), recipeName)
if tagRef.String() == "" {
return fmt.Errorf("%s is not available?", version)
}
@ -140,12 +165,92 @@ func EnsureVersion(recipeName, version string) error {
return err
}
opts := &git.CheckoutOptions{Branch: tagRef, Keep: true}
opts := &git.CheckoutOptions{
Branch: tagRef,
Create: false,
Force: true,
}
if err := worktree.Checkout(opts); err != nil {
return err
}
logrus.Debugf("successfully checked '%s' out to '%s' in '%s'", recipeName, tagRef, recipeDir)
logrus.Debugf("successfully checked '%s' out to '%s' in '%s'", recipeName, tagRef.Short(), recipeDir)
return nil
}
// EnsureLatest makes sure the latest commit is checkout on for a local recipe repository.
func EnsureLatest(recipeName string) error {
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
isClean, err := gitPkg.IsClean(recipeName)
if err != nil {
return err
}
if !isClean {
return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
}
if err := gitPkg.EnsureGitRepo(recipeDir); err != nil {
return err
}
logrus.Debugf("attempting to open git repository in '%s'", recipeDir)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in '%s'", path.Join(config.APPS_DIR, recipeName))
return err
}
branch = "main"
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out '%s' in '%s'", branch, recipeDir)
return err
}
return nil
}
// ChaosVersion constructs a chaos mode recipe version.
func ChaosVersion(recipeName string) (string, error) {
var version string
head, err := gitPkg.GetRecipeHead(recipeName)
if err != nil {
return version, err
}
version = head.String()[:8]
isClean, err := gitPkg.IsClean(recipeName)
if err != nil {
return version, err
}
if !isClean {
version = fmt.Sprintf("%s + unstaged changes", version)
}
return version, nil
}

View File

@ -17,8 +17,11 @@ func CreateServerDir(serverName string) error {
return err
}
logrus.Infof("'%s' already exists, moving on...", serverPath)
logrus.Infof("%s already exists", serverPath)
return nil
}
logrus.Infof("successfully created %s", serverPath)
return nil
}

604
pkg/ssh/ssh.go Normal file
View File

@ -0,0 +1,604 @@
package ssh
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
"time"
"coopcloud.tech/abra/pkg/context"
"github.com/AlecAivazis/survey/v2"
dockerSSHPkg "github.com/docker/cli/cli/connhelper/ssh"
sshPkg "github.com/gliderlabs/ssh"
"github.com/kevinburke/ssh_config"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
var KnownHostsPath = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts")
type Client struct {
SSHClient *ssh.Client
}
// HostConfig is a SSH host config.
type HostConfig struct {
Host string
IdentityFile string
Port string
User string
}
// Exec cmd on the remote host and return stderr and stdout
func (c *Client) Exec(cmd string) ([]byte, error) {
session, err := c.SSHClient.NewSession()
if err != nil {
return nil, err
}
defer session.Close()
return session.CombinedOutput(cmd)
}
// Close the underlying SSH connection
func (c *Client) Close() error {
return c.SSHClient.Close()
}
// New creates a new SSH client connection.
func New(domainName, sshAuth, username, port string) (*Client, error) {
var client *Client
ctxConnDetails, err := GetContextConnDetails(domainName)
if err != nil {
return client, nil
}
if sshAuth == "identity-file" {
var err error
client, err = connectWithAgentTimeout(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
5*time.Second,
)
if err != nil {
return client, err
}
} else {
password := ""
prompt := &survey.Password{
Message: "SSH password?",
}
if err := survey.AskOne(prompt, &password); err != nil {
return client, err
}
var err error
client, err = connectWithPasswordTimeout(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
password,
5*time.Second,
)
if err != nil {
return client, err
}
}
return client, nil
}
// sudoWriter supports sudo command handling
type sudoWriter struct {
b bytes.Buffer
pw string
stdin io.Writer
m sync.Mutex
}
// Write satisfies the write interface for sudoWriter
func (w *sudoWriter) Write(p []byte) (int, error) {
if string(p) == "sudo_password" {
w.stdin.Write([]byte(w.pw + "\n"))
w.pw = ""
return len(p), nil
}
w.m.Lock()
defer w.m.Unlock()
return w.b.Write(p)
}
// RunSudoCmd runs SSH commands and streams output
func RunSudoCmd(cmd, passwd string, cl *Client) error {
session, err := cl.SSHClient.NewSession()
if err != nil {
return err
}
defer session.Close()
cmd = "sudo -p " + "sudo_password" + " -S " + cmd
w := &sudoWriter{
pw: passwd,
}
w.stdin, err = session.StdinPipe()
if err != nil {
return err
}
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
}
<-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()
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
}
return nil
}
// EnsureKnowHostsFiles ensures that ~/.ssh/known_hosts is created
func EnsureKnowHostsFiles() error {
if _, err := os.Stat(KnownHostsPath); os.IsNotExist(err) {
logrus.Debugf("missing %s, creating now", KnownHostsPath)
file, err := os.OpenFile(KnownHostsPath, os.O_CREATE, 0600)
if err != nil {
return err
}
file.Close()
}
return nil
}
// GetHostKey checks if a host key is registered in the ~/.ssh/known_hosts file
func GetHostKey(hostname string) (bool, sshPkg.PublicKey, error) {
var hostKey sshPkg.PublicKey
ctxConnDetails, err := GetContextConnDetails(hostname)
if err != nil {
return false, hostKey, err
}
if err := EnsureKnowHostsFiles(); err != nil {
return false, hostKey, err
}
file, err := os.Open(KnownHostsPath)
if err != nil {
return false, hostKey, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), " ")
if len(fields) != 3 {
continue
}
hostnameAndPort := fmt.Sprintf("%s:%s", ctxConnDetails.Host, ctxConnDetails.Port)
hashed := knownhosts.Normalize(hostnameAndPort)
if strings.Contains(fields[0], hashed) {
var err error
hostKey, _, _, _, err = ssh.ParseAuthorizedKey(scanner.Bytes())
if err != nil {
return false, hostKey, fmt.Errorf("error parsing server SSH host key %q: %v", fields[2], err)
}
break
}
}
if hostKey != nil {
logrus.Debugf("server SSH host key present in ~/.ssh/known_hosts for %s", hostname)
return true, hostKey, nil
}
return false, hostKey, nil
}
// InsertHostKey adds a new host key to the ~/.ssh/known_hosts file
func InsertHostKey(hostname string, remote net.Addr, pubKey ssh.PublicKey) error {
file, err := os.OpenFile(KnownHostsPath, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer file.Close()
hashedHostname := knownhosts.Normalize(hostname)
lineHostname := knownhosts.Line([]string{hashedHostname}, pubKey)
_, err = file.WriteString(fmt.Sprintf("%s\n", lineHostname))
if err != nil {
return err
}
hashedRemote := knownhosts.Normalize(remote.String())
lineRemote := knownhosts.Line([]string{hashedRemote}, pubKey)
_, err = file.WriteString(fmt.Sprintf("%s\n", lineRemote))
if err != nil {
return err
}
logrus.Debugf("SSH host key generated: %s", lineHostname)
logrus.Debugf("SSH host key generated: %s", lineRemote)
return nil
}
// HostKeyAddCallback ensures server ssh host keys are handled
func HostKeyAddCallback(hostnameAndPort string, remote net.Addr, pubKey ssh.PublicKey) error {
exists, _, err := GetHostKey(hostnameAndPort)
if err != nil {
return err
}
if exists {
hostname := strings.Split(hostnameAndPort, ":")[0]
logrus.Debugf("server SSH host key found for %s, moving on", hostname)
return nil
}
if !exists {
hostname := strings.Split(hostnameAndPort, ":")[0]
parsedPubKey := FingerprintSHA256(pubKey)
fmt.Printf(fmt.Sprintf(`
You are attempting to make an SSH connection to a server but there is no entry
in your ~/.ssh/known_hosts file which confirms that this is indeed the server
you want to connect to. Please take a moment to validate the following SSH host
key, it is important.
Host: %s
Fingerprint: %s
If this is confusing to you, you can read the article below and learn how to
validate this fingerprint safely. Thanks to the comrades at cyberia.club for
writing this extensive guide <3
https://sequentialread.com/understanding-the-secure-shell-protocol-ssh/
`, hostname, parsedPubKey))
response := false
prompt := &survey.Confirm{
Message: "are you sure you trust this host key?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
logrus.Debugf("attempting to insert server SSH host key for %s, %s", hostnameAndPort, remote)
if err := InsertHostKey(hostnameAndPort, remote, pubKey); err != nil {
return err
}
logrus.Infof("successfully added server SSH host key for %s", hostname)
}
return nil
}
// connect makes the SSH connection
func connect(username, host, port string, authMethod ssh.AuthMethod, timeout time.Duration) (*Client, error) {
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{authMethod},
HostKeyCallback: HostKeyAddCallback, // the main reason why we fork
}
hostnameAndPort := fmt.Sprintf("%s:%s", host, port)
logrus.Debugf("tcp dialing %s", hostnameAndPort)
var conn net.Conn
var err error
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)
if err != nil {
return nil, err
}
conn, err = net.DialTimeout("tcp", fmt.Sprintf("%s:%s", hostConfig.Host, hostConfig.Port), timeout)
if err != nil {
return nil, err
}
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, hostnameAndPort, config)
if err != nil {
return nil, err
}
client := ssh.NewClient(sshConn, chans, reqs)
c := &Client{SSHClient: client}
return c, nil
}
func connectWithAgentTimeout(host, username, port string, timeout time.Duration) (*Client, error) {
logrus.Debugf("using ssh-agent to make an SSH connection for %s", host)
sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
if err != nil {
return nil, err
}
agentCl := agent.NewClient(sshAgent)
authMethod := ssh.PublicKeysCallback(agentCl.Signers)
loadedKeys, err := agentCl.List()
if err != nil {
return nil, err
}
var convertedKeys []string
for _, key := range loadedKeys {
convertedKeys = append(convertedKeys, key.String())
}
if len(convertedKeys) > 0 {
logrus.Debugf("ssh-agent has these keys loaded: %s", strings.Join(convertedKeys, ","))
} else {
logrus.Debug("ssh-agent has no keys loaded")
}
return connect(username, host, port, authMethod, timeout)
}
func connectWithPasswordTimeout(host, username, port, pass string, timeout time.Duration) (*Client, error) {
authMethod := ssh.Password(pass)
return connect(username, host, port, authMethod, timeout)
}
// EnsureHostKey ensures that a host key trusted and added to the ~/.ssh/known_hosts file
func EnsureHostKey(hostname string) error {
if hostname == "default" || hostname == "local" {
logrus.Debugf("not checking server SSH host key against local/default target")
return nil
}
exists, _, err := GetHostKey(hostname)
if err != nil {
return err
}
if exists {
return nil
}
ctxConnDetails, err := GetContextConnDetails(hostname)
if err != nil {
return err
}
_, err = connectWithAgentTimeout(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
5*time.Second,
)
if err != nil {
return err
}
return nil
}
// FingerprintSHA256 generates the SHA256 fingerprint for a server SSH host key
func FingerprintSHA256(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
b64hash := base64.StdEncoding.EncodeToString(hash[:])
trimmed := strings.TrimRight(b64hash, "=")
return fmt.Sprintf("SHA256:%s", trimmed)
}
// GetContextConnDetails retrieves SSH connection details from a docker context endpoint
func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) {
dockerContextStore := context.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List()
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
if strings.Contains(serverName, ":") {
serverName = strings.Split(serverName, ":")[0]
}
for _, ctx := range contexts {
endpoint, err := context.GetContextEndpoint(ctx)
if err != nil && strings.Contains(err.Error(), "does not exist") {
// No local context found, we can continue safely
continue
}
if ctx.Name == serverName {
ctxConnDetails, err := dockerSSHPkg.ParseURL(endpoint)
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
logrus.Debugf("found context connection details %v for %s", ctxConnDetails, serverName)
return ctxConnDetails, nil
}
}
hostConfig, err := GetHostConfig(serverName, "", "")
if err != nil {
return &dockerSSHPkg.Spec{}, err
}
logrus.Debugf("couldn't find a docker context matching %s", serverName)
logrus.Debugf("searching ~/.ssh/config for a Host entry for %s", serverName)
connDetails := &dockerSSHPkg.Spec{
Host: hostConfig.Host,
User: hostConfig.User,
Port: hostConfig.Port,
}
logrus.Debugf("using %v from ~/.ssh/config for connection details", connDetails)
return connDetails, nil
}
// GetHostConfig retrieves a ~/.ssh/config config for a host.
func GetHostConfig(hostname, username, port string) (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 username == "" {
if username = ssh_config.Get(hostname, "User"); username == "" {
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"
}
}
idf = ssh_config.Get(hostname, "IdentityFile")
if idf != "" {
var err error
idf, err = identityFileAbsPath(idf)
if err != nil {
return hostConfig, err
}
hostConfig.IdentityFile = idf
}
hostConfig.Host = host
hostConfig.Port = port
hostConfig.User = username
logrus.Debugf("constructed SSH config %s for %s", hostConfig, hostname)
return hostConfig, nil
}
func identityFileAbsPath(relPath string) (string, error) {
var err error
var absPath string
if strings.HasPrefix(relPath, "~/") {
systemUser, err := user.Current()
if err != nil {
return absPath, err
}
absPath = filepath.Join(systemUser.HomeDir, relPath[2:])
} else {
absPath, err = filepath.Abs(relPath)
if err != nil {
return absPath, err
}
}
logrus.Debugf("resolved %s to %s to read the ssh identity file", relPath, absPath)
return absPath, nil
}

View File

@ -0,0 +1,295 @@
// Package commandconn provides a net.Conn implementation that can be used for
// proxying (or emulating) stream via a custom command.
//
// For example, to provide an http.Client that can connect to a Docker daemon
// running in a Docker container ("DIND"):
//
// httpClient := &http.Client{
// Transport: &http.Transport{
// DialContext: func(ctx context.Context, _network, _addr string) (net.Conn, error) {
// return commandconn.New(ctx, "docker", "exec", "-it", containerID, "docker", "system", "dial-stdio")
// },
// },
// }
package commandconn
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
exec "golang.org/x/sys/execabs"
)
func createSession(cmd *exec.Cmd) {
// for supporting ssh connection helper with ProxyCommand
// https://github.com/docker/cli/issues/1707
cmd.SysProcAttr.Setsid = true
}
// New returns net.Conn
func New(ctx context.Context, cmd string, args ...string) (net.Conn, error) {
var (
c commandConn
err error
)
c.cmd = exec.CommandContext(ctx, cmd, args...)
// we assume that args never contains sensitive information
logrus.Debugf("commandconn: starting %s with %v", cmd, args)
c.cmd.Env = os.Environ()
c.cmd.SysProcAttr = &syscall.SysProcAttr{}
setPdeathsig(c.cmd)
createSession(c.cmd)
c.stdin, err = c.cmd.StdinPipe()
if err != nil {
return nil, err
}
c.stdout, err = c.cmd.StdoutPipe()
if err != nil {
return nil, err
}
c.cmd.Stderr = &stderrWriter{
stderrMu: &c.stderrMu,
stderr: &c.stderr,
debugPrefix: fmt.Sprintf("commandconn (%s):", cmd),
}
c.localAddr = dummyAddr{network: "dummy", s: "dummy-0"}
c.remoteAddr = dummyAddr{network: "dummy", s: "dummy-1"}
return &c, c.cmd.Start()
}
// commandConn implements net.Conn
type commandConn struct {
cmd *exec.Cmd
cmdExited bool
cmdWaitErr error
cmdMutex sync.Mutex
stdin io.WriteCloser
stdout io.ReadCloser
stderrMu sync.Mutex
stderr bytes.Buffer
stdioClosedMu sync.Mutex // for stdinClosed and stdoutClosed
stdinClosed bool
stdoutClosed bool
localAddr net.Addr
remoteAddr net.Addr
}
// killIfStdioClosed kills the cmd if both stdin and stdout are closed.
func (c *commandConn) killIfStdioClosed() error {
c.stdioClosedMu.Lock()
stdioClosed := c.stdoutClosed && c.stdinClosed
c.stdioClosedMu.Unlock()
if !stdioClosed {
return nil
}
return c.kill()
}
// killAndWait tries sending SIGTERM to the process before sending SIGKILL.
func killAndWait(cmd *exec.Cmd) error {
var werr error
if runtime.GOOS != "windows" {
werrCh := make(chan error)
go func() { werrCh <- cmd.Wait() }()
cmd.Process.Signal(syscall.SIGTERM)
select {
case werr = <-werrCh:
case <-time.After(3 * time.Second):
cmd.Process.Kill()
werr = <-werrCh
}
} else {
cmd.Process.Kill()
werr = cmd.Wait()
}
return werr
}
// kill returns nil if the command terminated, regardless to the exit status.
func (c *commandConn) kill() error {
var werr error
c.cmdMutex.Lock()
if c.cmdExited {
werr = c.cmdWaitErr
} else {
werr = killAndWait(c.cmd)
c.cmdWaitErr = werr
c.cmdExited = true
}
c.cmdMutex.Unlock()
if werr == nil {
return nil
}
wExitErr, ok := werr.(*exec.ExitError)
if ok {
if wExitErr.ProcessState.Exited() {
return nil
}
}
return errors.Wrapf(werr, "commandconn: failed to wait")
}
func (c *commandConn) onEOF(eof error) error {
// when we got EOF, the command is going to be terminated
var werr error
c.cmdMutex.Lock()
if c.cmdExited {
werr = c.cmdWaitErr
} else {
werrCh := make(chan error)
go func() { werrCh <- c.cmd.Wait() }()
select {
case werr = <-werrCh:
c.cmdWaitErr = werr
c.cmdExited = true
case <-time.After(10 * time.Second):
c.cmdMutex.Unlock()
c.stderrMu.Lock()
stderr := c.stderr.String()
c.stderrMu.Unlock()
return errors.Errorf("command %v did not exit after %v: stderr=%q", c.cmd.Args, eof, stderr)
}
}
c.cmdMutex.Unlock()
if werr == nil {
return eof
}
c.stderrMu.Lock()
stderr := c.stderr.String()
c.stderrMu.Unlock()
return errors.Errorf("command %v has exited with %v, please make sure the URL is valid, and Docker 18.09 or later is installed on the remote host: stderr=%s", c.cmd.Args, werr, stderr)
}
func ignorableCloseError(err error) bool {
errS := err.Error()
ss := []string{
os.ErrClosed.Error(),
}
for _, s := range ss {
if strings.Contains(errS, s) {
return true
}
}
return false
}
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
// 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
// logrus.Warnf("commandConn.CloseRead: %v", err)
}
return nil
}
func (c *commandConn) Read(p []byte) (int, error) {
n, err := c.stdout.Read(p)
if err == io.EOF {
err = c.onEOF(err)
}
return n, err
}
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
// 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
// logrus.Warnf("commandConn.CloseWrite: %v", err)
}
return nil
}
func (c *commandConn) Write(p []byte) (int, error) {
n, err := c.stdin.Write(p)
if err == io.EOF {
err = c.onEOF(err)
}
return n, err
}
func (c *commandConn) Close() error {
var err error
if err = c.CloseRead(); err != nil {
logrus.Warnf("commandConn.Close: CloseRead: %v", err)
}
if err = c.CloseWrite(); err != nil {
// TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.Close: CloseWrite: %v", err)
}
return err
}
func (c *commandConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *commandConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
func (c *commandConn) SetDeadline(t time.Time) error {
logrus.Debugf("unimplemented call: SetDeadline(%v)", t)
return nil
}
func (c *commandConn) SetReadDeadline(t time.Time) error {
logrus.Debugf("unimplemented call: SetReadDeadline(%v)", t)
return nil
}
func (c *commandConn) SetWriteDeadline(t time.Time) error {
logrus.Debugf("unimplemented call: SetWriteDeadline(%v)", t)
return nil
}
type dummyAddr struct {
network string
s string
}
func (d dummyAddr) Network() string {
return d.network
}
func (d dummyAddr) String() string {
return d.s
}
type stderrWriter struct {
stderrMu *sync.Mutex
stderr *bytes.Buffer
debugPrefix string
}
func (w *stderrWriter) Write(p []byte) (int, error) {
logrus.Debugf("%s%s", w.debugPrefix, string(p))
w.stderrMu.Lock()
if w.stderr.Len() > 4096 {
w.stderr.Reset()
}
n, err := w.stderr.Write(p)
w.stderrMu.Unlock()
return n, err
}

View File

@ -0,0 +1,103 @@
package commandconn
import (
"context"
"fmt"
"net"
"net/url"
sshPkg "coopcloud.tech/abra/pkg/ssh"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/cli/cli/connhelper/ssh"
"github.com/docker/cli/cli/context/docker"
dCliContextStore "github.com/docker/cli/cli/context/store"
dClient "github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// GetConnectionHelper returns Docker-specific connection helper for the given URL.
// GetConnectionHelper returns nil without error when no helper is registered for the scheme.
//
// ssh://<user>@<host> URL requires Docker 18.09 or later on the remote host.
func GetConnectionHelper(daemonURL string) (*connhelper.ConnectionHelper, error) {
return getConnectionHelper(daemonURL, []string{"-o ConnectTimeout=5"})
}
func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.ConnectionHelper, error) {
url, err := url.Parse(daemonURL)
if err != nil {
return nil, err
}
switch scheme := url.Scheme; scheme {
case "ssh":
ctxConnDetails, err := ssh.ParseURL(daemonURL)
if err != nil {
return nil, errors.Wrap(err, "ssh host connection is not valid")
}
if err := sshPkg.EnsureHostKey(ctxConnDetails.Host); err != nil {
return nil, err
}
hostConfig, err := sshPkg.GetHostConfig(
ctxConnDetails.Host,
ctxConnDetails.User,
ctxConnDetails.Port,
)
if err != nil {
return nil, err
}
if hostConfig.IdentityFile != "" {
msg := "discovered %s as identity file for %s, using for ssh connection"
logrus.Debugf(msg, hostConfig.IdentityFile, ctxConnDetails.Host)
sshFlags = append(sshFlags, fmt.Sprintf("-o IdentityFile=%s", hostConfig.IdentityFile))
}
return &connhelper.ConnectionHelper{
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
return New(ctx, "ssh", append(sshFlags, ctxConnDetails.Args("docker", "system", "dial-stdio")...)...)
},
Host: "http://docker.example.com",
}, nil
}
// Future version may support plugins via ~/.docker/config.json. e.g. "dind"
// See docker/cli#889 for the previous discussion.
return nil, err
}
func NewConnectionHelper(daemonURL string) *connhelper.ConnectionHelper {
helper, err := GetConnectionHelper(daemonURL)
if err != nil {
logrus.Fatal(err)
}
return helper
}
func getDockerEndpoint(host string) (docker.Endpoint, error) {
skipTLSVerify := false
ep := docker.Endpoint{
EndpointMeta: docker.EndpointMeta{
Host: host,
SkipTLSVerify: skipTLSVerify,
},
}
// try to resolve a docker client, validating the configuration
opts, err := ep.ClientOpts()
if err != nil {
return docker.Endpoint{}, err
}
if _, err := dClient.NewClientWithOpts(opts...); err != nil {
return docker.Endpoint{}, err
}
return ep, nil
}
func GetDockerEndpointMetadataAndTLS(host string) (docker.EndpointMeta, *dCliContextStore.EndpointTLSData, error) {
ep, err := getDockerEndpoint(host)
if err != nil {
return docker.EndpointMeta{}, nil, err
}
return ep.EndpointMeta, ep.TLSData.ToStoreTLSData(), nil
}

View File

@ -0,0 +1,10 @@
package commandconn
import (
"os/exec"
"syscall"
)
func setPdeathsig(cmd *exec.Cmd) {
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
}

View File

@ -0,0 +1,11 @@
//go:build !linux
// +build !linux
package commandconn
import (
"os/exec"
)
func setPdeathsig(cmd *exec.Cmd) {
}

View File

@ -1,4 +1,4 @@
package container
package container // https://github.com/docker/cli/blob/master/cli/command/container/exec.go
import (
"context"

View File

@ -1,4 +1,4 @@
package container
package container // https://github.com/docker/cli/blob/master/cli/command/container/hijack.go
import (
"context"

View File

@ -1,4 +1,4 @@
package container
package container // https://github.com/docker/cli/blob/master/cli/command/container/tty.go
import (
"context"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/command/container/tty.go
import (
"io/ioutil"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/compose_test.go
import (
"testing"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/service.go
import (
"context"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/service_test.go
import (
"context"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/volume.go
import (
composetypes "github.com/docker/cli/cli/compose/types"

View File

@ -1,4 +1,4 @@
package convert
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/volume_test.go
import (
"testing"

View File

@ -1,4 +1,4 @@
package stack
package stack // https://github.com/docker/cli/blob/master/cli/command/stack/loader/loader.go
import (
"fmt"

View File

@ -1,4 +1,4 @@
package stack
package stack // https://github.com/docker/cli/blob/master/cli/command/stack/options/opts.go
// Deploy holds docker stack deploy options
type Deploy struct {

View File

@ -1,4 +1,4 @@
package stack
package stack // https://github.com/docker/cli/blob/master/cli/command/stack/remove.go
import (
"context"

View File

@ -1,11 +1,16 @@
package stack
package stack // https://github.com/docker/cli/blob/master/cli/command/stack/swarm/common.go
import (
"context"
"fmt"
"io"
"io/ioutil"
"strings"
"time"
abraClient "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/client/convert"
"coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/cli/cli/command/service/progress"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@ -92,6 +97,50 @@ func GetAllDeployedServices(contextName string) StackStatus {
return StackStatus{services, nil}
}
// GetDeployedServicesByName filters services by name
func GetDeployedServicesByName(ctx context.Context, cl *dockerclient.Client, stackName, serviceName string) StackStatus {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", stackName, serviceName))
services, err := cl.ServiceList(ctx, types.ServiceListOptions{Filters: filters})
if err != nil {
return StackStatus{[]swarm.Service{}, err}
}
return StackStatus{services, nil}
}
// IsDeployed chekcks whether an appp is deployed or not.
func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) {
version := ""
isDeployed := false
filter := filters.NewArgs()
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
services, err := cl.ServiceList(ctx, types.ServiceListOptions{Filters: filter})
if err != nil {
return false, version, err
}
if len(services) > 0 {
for _, service := range services {
labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName)
if deployedVersion, ok := service.Spec.Labels[labelKey]; ok {
version = deployedVersion
break
}
}
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)
return isDeployed, version, nil
}
// pruneServices removes services that are no longer referenced in the source
func pruneServices(ctx context.Context, cl *dockerclient.Client, namespace convert.Namespace, services map[string]struct{}) {
oldServices, err := GetStackServices(ctx, cl, namespace.Name())
@ -301,6 +350,7 @@ func deployServices(
existingServiceMap[service.Spec.Name] = service
}
var serviceIDs []string
for internalName, serviceSpec := range services {
var (
name = namespace.Scope(internalName)
@ -360,6 +410,8 @@ func deployServices(
return errors.Wrapf(err, "failed to update service %s", name)
}
serviceIDs = append(serviceIDs, service.ID)
for _, warning := range response.Warnings {
logrus.Warn(warning)
}
@ -373,11 +425,35 @@ func deployServices(
createOpts.QueryRegistry = true
}
if _, err := cl.ServiceCreate(ctx, serviceSpec, createOpts); err != nil {
serviceCreateResponse, err := cl.ServiceCreate(ctx, serviceSpec, createOpts)
if err != nil {
return errors.Wrapf(err, "failed to create service %s", name)
}
serviceIDs = append(serviceIDs, serviceCreateResponse.ID)
}
}
logrus.Infof("waiting for services to converge: %s", strings.Join(serviceIDs, ", "))
ch := make(chan error, len(serviceIDs))
for _, serviceID := range serviceIDs {
logrus.Debugf("waiting on %s to converge", serviceID)
go func(s string) {
ch <- waitOnService(ctx, cl, s)
}(serviceID)
}
for _, serviceID := range serviceIDs {
err := <-ch
if err != nil {
return err
}
logrus.Debugf("assuming %s converged successfully", serviceID)
}
logrus.Info("services converged 👌")
return nil
}
@ -392,3 +468,25 @@ func getStackSecrets(ctx context.Context, dockerclient client.APIClient, namespa
func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespace string) ([]swarm.Config, error) {
return dockerclient.ConfigList(ctx, types.ConfigListOptions{Filters: getStackFilter(namespace)})
}
// 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 {
errChan := make(chan error, 1)
pipeReader, pipeWriter := io.Pipe()
go func() {
errChan <- progress.ServiceProgress(ctx, cl, serviceID, pipeWriter)
}()
go io.Copy(ioutil.Discard, pipeReader)
timeout := 60 * 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)
}
}

3
renovate.json Normal file
View File

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@ -1,7 +1,16 @@
#!/usr/bin/env bash
ABRA_VERSION="0.1.8-alpha"
ABRA_VERSION="0.3.0-alpha"
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION"
RC_VERSION="0.3.1-alpha-rc2"
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION"
for arg in "$@"; do
if [ "$arg" == "--rc" ]; then
ABRA_VERSION="$RC_VERSION"
ABRA_RELEASE_URL="$RC_VERSION_URL"
fi
done
function show_banner {
echo ""
@ -18,6 +27,13 @@ function show_banner {
echo ""
}
function print_checksum_error {
echo "$(tput setaf 1)ERROR: the checksum of downloaded file doesn't match the checksum in release!!! Either the file was corrupted during download or the file has been changed during transport!$(tput sgr0)"
echo "expected checksum: $checksum"
echo "checksum of downloaded file: $localsum"
echo "abra was NOT installed/upgraded"
}
function install_abra_release {
mkdir -p "$HOME/.local/bin"
@ -28,26 +44,52 @@ function install_abra_release {
exit 1
fi
if ! type "curl" > /dev/null 2>&1; then
error "'python3' is not installed, cannot proceed..."
echo "perhaps try installing manually via the releases URL?"
echo "https://git.coopcloud.tech/coop-cloud/abra/releases"
fi
# FIXME: support different architectures
release_url=$(curl -s "$ABRA_RELEASE_URL" |
python3 -c "import sys, json; \
payload = json.load(sys.stdin); \
url = [a['browser_download_url'] for a in payload['assets'] if 'linux_x86_64' in a['name']][0]; \
print(url)")
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m)
FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM""
sed_command_rel='s/.*"assets":\[\{[^]]*"name":"'$FILENAME'"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'
sed_command_checksums='s/.*"assets":\[\{[^\]*"name":"checksums.txt"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'
echo "downloading $ABRA_VERSION x86_64 binary release for abra..."
curl --progress-bar "$release_url" --output "$HOME/.local/bin/abra"
json=$(curl -s $ABRA_RELEASE_URL)
release_url=$(echo $json | sed -En $sed_command_rel)
checksums_url=$(echo $json | sed -En $sed_command_checksums)
checksums=$(curl -s $checksums_url)
checksum=$(echo "$checksums" | grep "$FILENAME" - | sed -En 's/([0-9a-f]{64})\s+'"$FILENAME"'.*/\1/p')
echo "downloading $ABRA_VERSION $PLATFORM binary release for abra..."
curl --progress-bar "$release_url" --output "$HOME/.local/bin/.abra-download"
localsum=$(sha256sum $HOME/.local/bin/.abra-download | sed -En 's/([0-9a-f]{64})\s+.*/\1/p')
echo "checking if checksums match..."
if [[ "$localsum" != "$checksum" ]]; then
print_checksum_error
exit 1
fi
echo "$(tput setaf 2)check successful!$(tput sgr0)"
mv "$HOME/.local/bin/.abra-download" "$HOME/.local/bin/abra"
chmod +x "$HOME/.local/bin/abra"
x=$(echo $PATH | grep $HOME/.local/bin)
if [ $? -ne 0 ]; then
echo "$(tput setaf 3)WARNING: $HOME/.local/bin/ is not in \$PATH! If you want to run abra by just typing "abra" you should add it to your \$PATH! To do that run:$(tput sgr0)"
p=$HOME/.local/bin
com="echo PATH=\$PATH:$p"
if [[ $SHELL =~ "bash" ]]; then
echo "echo $com >> $HOME/.bashrc"
elif [[ $SHELL =~ "fizsh" ]]; then
echo "echo $com >> $HOME/.fizsh/.fizshrc"
elif [[ $SHELL =~ "zsh" ]]; then
echo "echo $com >> $HOME/.zshrc"
else
echo "echo $com >> $HOME/.profile"
fi
fi
echo "abra installed to $HOME/.local/bin/abra"
}
function run_installation {
show_banner
install_abra_release