Compare commits

...

160 Commits

Author SHA1 Message Date
1f9b863be0
fix: appease formatter, ignore vendor
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-21 16:46:39 +02:00
3b3ce85ef9
fix: rebase coop-cloud/organising#533
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2024-10-21 16:39:36 +02:00
1f8662cd95
refactor: urfave v3 2024-10-21 16:39:27 +02:00
375e17a4a0
refactor: urfave v2 2024-10-21 11:00:35 +02:00
04aec8232f
chore: vendor
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-04 11:06:58 +02:00
2a5985e44e
build: drop 2MB with GCFLAGS [ci skip] 2024-07-27 12:56:43 +02:00
c65be64e7d
fix: dont checkout version for abra app undeploy
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#628
2024-07-24 16:09:27 +02:00
fd8652e26d
fix: --chaos/--offline for abra app ps
See coop-cloud/organising#628
See coop-cloud/organising#629
2024-07-24 16:09:03 +02:00
518c5795f4
fix: avoid overwriting non version env vars
See coop-cloud/organising#630
2024-07-24 16:07:08 +02:00
827edcb0da
test: full width for CI testing [ci skip]
Also clean up the .env.sample.
2024-07-18 11:03:02 +02:00
05489a129c
test: re-create serer for setup [ci skip] 2024-07-17 14:32:53 +02:00
c02e11eb0a
test: fix order of teardown [ci skip] 2024-07-17 14:15:03 +02:00
8b8e158664
test: int suite fixes
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-17 14:05:46 +02:00
e5a6dea10c
fix: catch ctrl-c again; less cryptic logging
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-17 10:09:09 +02:00
1132b09b5b
fix: error out for invalid env versions 2024-07-17 10:08:51 +02:00
b2436174b0
chore: more logging for env versions 2024-07-17 10:08:32 +02:00
ea10019068
fix: "secret insert" respects env version 2024-07-17 10:08:13 +02:00
9b0b3c2e4c
fix: override version from CLI
See coop-cloud/organising#541
2024-07-17 10:07:47 +02:00
8084bff104
test: env version tests
See coop-cloud/organising#541
2024-07-17 10:06:46 +02:00
d22e2c38ce
test: just build main
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-17 08:29:58 +02:00
e945087f79
test: env version writing tests 2024-07-17 08:27:12 +02:00
7734dd555d
fix: spacer between multiple versions 2024-07-17 02:12:26 +02:00
aedf5e5ff7
fix: dont write commented out versions
See coop-cloud/organising#626
2024-07-17 01:56:28 +02:00
95c598d030
feat: "app new" supports writing env files
And, automagically, chaos commit hashes.
2024-07-17 01:45:19 +02:00
56068362e8
fix: write versions on deploy/upgrade/rollback
See coop-cloud/organising#625
2024-07-17 01:29:49 +02:00
cf14731b46
refactor: "false" -> CHAOS_DEFAULT 2024-07-17 01:23:12 +02:00
486cfa68d8
test: explode on failures
All checks were successful
continuous-integration/drone/push Build is passing
Closes coop-cloud/organising#623
2024-07-17 00:16:47 +02:00
1718903834
test: reset recipe before undeploying [ci skip] 2024-07-17 00:00:06 +02:00
eb9894e5bb
test: dont clone if exists [ci skip] 2024-07-16 23:51:28 +02:00
a2116774e8
test: ensure catalogue in place [ci skip] 2024-07-16 23:46:02 +02:00
d2efdf8bf5
test: adjust output checking [ci skip] 2024-07-16 23:39:10 +02:00
b15c05929c
test: adjust output checking [ci skip] 2024-07-16 23:32:12 +02:00
f167a91868
test: skip for now, it's flaking again [ci skip]
We need to solve coop-cloud/organising#541
2024-07-16 23:28:18 +02:00
8cded8752a
test: ensure correct server for diffing [ci skip] 2024-07-16 23:25:17 +02:00
d1876e2fae
test: do exact diff of JSON for integration
All checks were successful
continuous-integration/drone/push Build is passing
See coop-cloud/organising#627
2024-07-16 23:19:36 +02:00
e42a1bca29
fix: add chaos/deploy versiosn back to ps output
All checks were successful
continuous-integration/drone/push Build is passing
Fix to support alakazam parsing
2024-07-16 22:47:47 +02:00
b5493ba059
refactor: CreateTable2 -> CreateTable [ci skip] 2024-07-16 22:45:03 +02:00
a41a36b8fd
fix: dont lock existing version on rollback
All checks were successful
continuous-integration/drone/push Build is passing
Otherwise, we can't select previous versions.
2024-07-16 17:35:15 +02:00
de006782b6
refactor: tablewriter -> lipgloss
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Also the jsontable impl. is dropped also. Output is unchanged.
2024-07-16 16:22:47 +02:00
f28cffe6d8
refactor: vertical deploy overview 2024-07-16 09:37:10 +02:00
d3ede0f0f6
refactor: logging with background/padding 2024-07-15 22:55:02 +02:00
ae4653f5e3
build: add full install target [ci skip] 2024-07-13 15:30:38 +02:00
f
7f0a74d3c3
fix: source autocompletion on the current terminal
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-11 12:02:38 -03:00
f
e99114e695
fix: setup should be run once 2024-07-11 12:02:22 -03:00
f
b1208f9db5
fix: sometimes the completion directories already exist 2024-07-11 12:01:21 -03:00
b8e1a3b75f
test: remote recipe tests
All checks were successful
continuous-integration/drone/push Build is passing
See #432
2024-07-10 16:03:28 +02:00
ff90b43929
fix: use struct data for HEAD retrieval
All checks were successful
continuous-integration/drone/push Build is passing
See ce7dda1eae
2024-07-10 15:51:11 +02:00
c5724d56f8 fix(config): Removes config file name from abra dir
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-10 13:42:24 +00:00
ce7dda1eae
fix: use recipe struct data
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Follow up for #432
2024-07-10 15:40:45 +02:00
d38f3ab7f5
test: speed up test
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-10 13:27:58 +02:00
4be8c8daed
test: fix outputs [ci skip]
See https://build.coopcloud.tech/coop-cloud/abra/2035/1/5
2024-07-10 13:20:39 +02:00
3c9405a4ed
refactor!: --problems/p goes away
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Follow up for #413
2024-07-10 13:06:46 +02:00
f6b7510da6 feat: introduce remote recipes
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #432
2024-07-10 10:25:06 +00:00
7596982282 feat: update new version in env file
All checks were successful
continuous-integration/drone/pr Build is passing
2024-07-10 12:12:43 +02:00
4085eb6654 feat: define recipe version inside app env file 2024-07-10 12:11:46 +02:00
790dbca362 feat!: remove all catalogue reads from app commands 2024-07-10 12:06:57 +02:00
d7a870b887 feat: remote recipes 2024-07-10 12:06:44 +02:00
1a3ec7a107
fix: pass recipe name for listing cmds
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-09 17:23:06 +02:00
7f910b4e5b
test: recipe test fixups
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-09 11:34:20 +02:00
b82ac3bd63
refactor: make IsChaos an actual bool 2024-07-09 11:34:01 +02:00
00d60f7114
fix: ensure force upgrade/rollback works 2024-07-09 11:33:33 +02:00
71d93cbbea
refactor: debug logging and errors for version issues 2024-07-09 11:33:07 +02:00
2fb5493ab5
feat: support chaos commits on deploy
See coop-cloud/organising#517
2024-07-09 11:31:52 +02:00
0ff8e49cfd
docs: pass on sub-command help 2024-07-09 09:43:18 +02:00
addbda9145
test: fixups for the changepocalypse
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-09 09:41:49 +02:00
c33ca1c6bc
fix!: chaos consistency (deploy/undeploy/rollback/upgrade)
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#559

--chaos for rollback/upgrade goes away.
2024-07-08 17:23:49 +02:00
4580df72cb
fix: use recipe name
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 14:58:57 +02:00
f003430a8d
fix: use recipe name, not app name
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 14:54:15 +02:00
5426464092
refactor!: drop version, show versions in ps
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#526
See coop-cloud/organising#502
2024-07-08 14:41:46 +02:00
72c021c727
fix: remove old commands from deploy fail help
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 14:29:51 +02:00
f2e076b35f
fix: set default logger on kadabra 2024-07-08 14:26:27 +02:00
4ccb4198d6
fix: "recipe version" handles non-catalogue recipes 2024-07-08 14:26:26 +02:00
a9f7579ca9
fix: remove old logrus calls 2024-07-08 14:21:17 +02:00
9cd1fe658b refactor(recipe): create a recipe struct that gets used everywhere #430
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #430
2024-07-08 12:18:58 +00:00
41c16db670 test: fix test failure
All checks were successful
continuous-integration/drone/pr Build is passing
2024-07-08 14:10:17 +02:00
87ecc05962 refactor(recipe): remove direct usage of config.RECIPE_DIR
Some checks failed
continuous-integration/drone/pr Build is failing
2024-07-08 13:48:02 +02:00
f14d49cc64 refactor(recipe): rename Recipe2 -> Recipe 2024-07-08 13:19:40 +02:00
f638b6a16b refator(recipe): remove old struct 2024-07-08 13:16:47 +02:00
5617a9ba07 refactor(recipe): remove remaining usage of old recipe struct 2024-07-08 13:15:20 +02:00
c1b03bcbd7 refactor(recipe): load load compoes config where its used 2024-07-08 12:31:39 +02:00
99da8d4e57 refactor(recipe): move GetComposeFiles to new struct 2024-07-08 12:06:58 +02:00
ca1db33e97 refactor(recipe): remove Dir method on old struct 2024-07-08 11:48:53 +02:00
eb62e0ecc3 refactor(recipe): move Tags method to new struct 2024-07-08 11:45:47 +02:00
6f90fc3025 refactor(recipe): don't use README.md path directly 2024-07-08 11:43:18 +02:00
c861c09cce refactor(recipe): use method or variable for .env.sample 2024-07-08 11:41:26 +02:00
2f41b6d8b4 refactor(recipe): store sample env path in new struct 2024-07-08 11:31:55 +02:00
73e9b818b4 refactor(recipe): move SampleEnv method to new struct 2024-07-08 11:02:43 +02:00
f268e5893b refactor(recipe): move functions that operate on the git repo to new file 2024-07-08 11:00:50 +02:00
47013c63d6 refactor(recipe): use template for ssh url 2024-07-08 10:56:08 +02:00
4cf6155fb8 refactor(recipe): introduce Dir var 2024-07-08 10:56:08 +02:00
01f3f4be17 refactor(recipe): use new recipe.Ensure method 2024-07-08 10:55:55 +02:00
eee2ecda06 refactor(recipe): add offline and chaos options to Ensure method 2024-07-08 10:55:55 +02:00
950f85e2b4 refactor(recipe): introduce new recipe struct and move some methods 2024-07-08 10:55:43 +02:00
9ef64778f5
chore: go deps update
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 01:52:17 +02:00
735f521bc0
refactor(errors)!: remove WIP/broken command
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 01:33:06 +02:00
96a25425a4
refactor(ps)!: remove -w, "watch ..." does it better
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 01:10:58 +02:00
1a8dca9804
fix(deploy): only output when actually waiting
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-08 01:01:14 +02:00
465827d5ee
log: no additional newlines 2024-07-08 01:01:14 +02:00
cde06f4f00
log: output caller on debug, use stdout as default 2024-07-08 01:01:13 +02:00
050a479df7
refactor: "service name" -> "service" 2024-07-08 00:38:54 +02:00
ef108d63e1
refactor: use central logger
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-08 00:01:28 +02:00
cf8ff410cc
feat: central log config
See coop-cloud/organising#422
2024-07-08 00:01:27 +02:00
6ec678208f
chore: formatting 2024-07-07 22:40:06 +02:00
a001be3021
docs: better "app ps" description 2024-07-07 22:39:57 +02:00
6712bd446f
chore: add upstream link
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-07 21:52:45 +02:00
1097daa69f
fix: "abra app restart" docs + --all-services
See coop-cloud/organising#605
2024-07-07 21:52:24 +02:00
beaa233421
test: only publish image on main merge
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-07-07 12:21:51 +02:00
f871f9beee
test: reduce duplication
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-07 12:13:07 +02:00
0f8f0f908f
test: ensure catalogue
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-07 12:03:43 +02:00
c5211fbd7e
test: fix imports 2024-07-07 12:03:37 +02:00
0076b31253 new package envfile and move GetComposeFiles to recipe package
Some checks failed
continuous-integration/drone/pr Build is failing
2024-07-06 16:37:16 +02:00
37aff723c0 move GetComposeFiles 2024-07-06 16:37:16 +02:00
f18c642226 refactor: move app files from config to app package 2024-07-06 16:37:16 +02:00
ac695ae28e feat: introduce abra config file and load abra dir from it (!419)
All checks were successful
continuous-integration/drone/push Build is passing
This is the first step to introduce a configuration file for abra. The config file must be named `abra.yaml` or àbra.yml`. abra look for the config file in the current directory and when not found traverses the directory tree up until it is found or the home/root directory is reached.

For now there is only one setting that is made configurable: `abraDir`. The new logic for setting the abra dir is the following:
1. lookup `$ABRA_DIR` env
2. look for config file and take value from there
3. `$HOME/.abra` as fallback

See coop-cloud/organising#303.

Reviewed-on: #419
Reviewed-by: decentral1se <decentral1se@noreply.git.coopcloud.tech>
Co-authored-by: p4u1 <p4u1_f4u1@riseup.net>
Co-committed-by: p4u1 <p4u1_f4u1@riseup.net>
2024-07-06 14:36:31 +00:00
ac87898005
test: run versioned script [ci skip] 2024-07-03 10:02:04 +02:00
32ae2499b6
test: add CI integration script [ci skip] 2024-07-03 09:57:22 +02:00
1136ec5dcd
build: remove old release scripts 2024-07-03 09:57:06 +02:00
6a2db1abaa
test: run int suite on remote server via cron
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-02 17:18:05 +02:00
9554ad40c8
refactor: use adapted upstream detach=false logic [ci skip]
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#607.
2024-07-02 14:52:12 +02:00
2014cd6622
test: less fragile integration suite [ci skip]
See coop-cloud/organising#584
See coop-cloud/organising#595
2024-07-02 12:16:58 +02:00
a9ce2106c6
test: skip test for now
All checks were successful
continuous-integration/drone/push Build is passing
Also, don't build image if tests fail.
2024-06-28 06:12:32 +02:00
34de38928a
test: include catalogue
Some checks failed
continuous-integration/drone/push Build is failing
2024-06-26 23:46:35 +02:00
f58522d822
fix: dont always download the catalogue
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
See coop-cloud/organising#592
2024-06-25 16:48:41 +02:00
712ebfb701
test: update and fix cleanup for "server add"
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-25 16:24:44 +02:00
1fe601cd16
fix: custom timeout only for "server add" 2024-06-25 16:13:57 +02:00
7b7e1bfa97
refactor!: server add/rm has better UI/UX
Less confusing logging messages, clear "is created" / "already exists"
output. Move the majority of logging to debug output to not confuse the
situation. Some code cleanups also in there.
2024-06-25 09:48:53 +02:00
1a12bef53e
docs: better "server add" help output 2024-06-25 09:24:01 +02:00
d787f71215
fix: more accurate dns errors
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-25 00:27:48 +02:00
9bf44c15ed
fix: clean up if failed to create context 2024-06-25 00:27:34 +02:00
349cacc1f2
docs: explain -D for "server add" 2024-06-25 00:27:16 +02:00
938534f5ac feat: support non-TLD resolving server domains
All checks were successful
continuous-integration/drone/push Build is passing
See coop-cloud/organising#566
2024-06-24 22:07:16 +00:00
6cd331ebd6 secret: allow inserting secret from file and add trim flag
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-22 16:49:59 +00:00
40517171f7
test: separate test for git name/email
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See #405
2024-06-22 18:46:28 +02:00
b2485cc122 feat: add git-user and git-email flags to recipe new
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-22 16:38:32 +00:00
9ec99c7712 test: return/echo from git helper functions
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-22 17:04:33 +02:00
aa3910f8df
refactor!: drop all SSH opts / config handling
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#601
See coop-cloud/organising#482
2024-06-21 17:16:41 +02:00
43990b6fae
test: use more plumbung for git output
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-21 17:10:12 +02:00
91ea2c01a5
fix: fix old app version deploy wrt. compose files
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#617
2024-06-21 16:14:40 +02:00
316fdd3643
fix: abra app new checks out latest version
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
See coop-cloud/organising#618
2024-06-21 15:51:34 +02:00
e07ae8cccd
chore: make format/check
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-19 19:17:22 +02:00
300a4ead01
fix: stop using deprecated APIs
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2024-06-19 19:14:52 +02:00
f209b6f564
chore: go get -u -t 2024-06-19 19:14:44 +02:00
791183adfe
build: new deps target 2024-06-19 19:14:31 +02:00
e6b35e8524 fix(upgrade): make upgrade --chaos working again
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-22 10:21:31 +02:00
8a0274cac0 fix(recipe): output correct formatted json for recipe version
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-21 16:59:59 +02:00
e609924af0 feat(upgrade): add --releasenotes: show release notes and skip upgrading
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-21 13:49:36 +02:00
70e2943301 fix(upgrade): only show release notes relevant for the upgrade 2024-05-21 13:49:11 +02:00
0590c1824d checkout deployed version
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-14 00:07:58 +02:00
459abecfa5 only show container that should be deployed
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-13 23:26:02 +02:00
183ad8f576 machine readable ps output
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-05-13 22:08:03 +02:00
03f94da2d8
docs: add fauno [ci skip] 2024-05-01 01:20:25 +02:00
f
766f69b0fd
feat: strip debug symbols
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
to produce smaller binaries
2024-04-30 14:05:03 -03:00
004cd70aed
fix: use unique rule number & wording [ci skip] 2024-04-06 23:52:56 +02:00
a4de446f58
test: more verbose failure msg, use contains [ci skip] 2024-04-06 23:48:22 +02:00
d21c35965d fix: add warning for long secret names (!359)
All checks were successful
continuous-integration/drone/push Build is passing
A start of a fix for coop-cloud/organising#463
Putting some code out to start a discussion.  I've added a linting rule for recipes to establish a general principal but I want to put some validation into cli/app/new.go as that's the point we have both the recipe and the domain and can say for sure whether or not the secret names lengths cause a problem but that will have to wait for a bit.  Let me know if I've missed the mark somewhere

Reviewed-on: #359
Reviewed-by: decentral1se <decentral1se@noreply.git.coopcloud.tech>
Co-authored-by: Rich M <r.p.makepeace@gmail.com>
Co-committed-by: Rich M <r.p.makepeace@gmail.com>
2024-04-06 21:41:37 +00:00
63ea58ffaa add relevant command to error message
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-01 18:51:53 +01:00
2ecace3e90
fix: add missing packages on final layer
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Closes coop-cloud/organising#598
2024-04-01 13:57:51 +02:00
d5ac3958a4 feat: add retries to app volume remove
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-27 05:38:24 +00:00
3wc
72c20e0039 fix: make installer work again
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-26 21:07:38 -03:00
575f9905f1
Revert "Revert "feat: backup revolution""
All checks were successful
continuous-integration/drone/push Build is passing
This reverts commit 2c515ce70a.
2024-03-12 10:34:40 +01:00
3728 changed files with 981526 additions and 6755 deletions

View File

@ -10,10 +10,10 @@ steps:
- name: make test
image: golang:1.21
environment:
ABRA_DIR: "/root/.abra"
CATL_URL: https://git.coopcloud.tech/coop-cloud/recipes-catalogue-json.git
commands:
- make build-abra
- ./abra help # show version, initialise $ABRA_DIR
- mkdir -p $HOME/.abra
- git clone $CATL_URL $HOME/.abra/catalogue
- make test
depends_on:
- make check
@ -54,11 +54,35 @@ steps:
tags: dev
registry: git.coopcloud.tech
when:
event:
exclude:
- pull_request
branch:
- main
depends_on:
- make check
- make test
- name: integration test
image: appleboy/drone-ssh
settings:
host:
- int.coopcloud.tech
username: abra
key:
from_secret: abra_int_private_key
port: 22
command_timeout: 60m
script_stop: true
request_pty: true
script:
- |
wget https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
chmod +x run-ci-int
sh run-ci-int
when:
event:
- cron:
cron:
# @daily https://docs.drone.io/cron/
- integration
volumes:
- name: deps

View File

@ -1,7 +1,7 @@
go env -w GOPRIVATE=coopcloud.tech
# export PASSWORD_STORE_DIR=$(pwd)/../../autonomic/passwords/passwords/
# integration test suite
# export ABRA_DIR="$HOME/.abra_test"
# export ABRA_TEST_DOMAIN=test.example.com
# export ABRA_SKIP_TEARDOWN=1 # for faster feedback when developing tests
# export ABRA_CI=1
# release automation
# export GITEA_TOKEN=

1
.gitignore vendored
View File

@ -6,4 +6,3 @@
abra
dist/
tests/integration/.bats
vendor/

View File

@ -29,6 +29,8 @@ builds:
ldflags:
- "-X 'main.Commit={{ .Commit }}'"
- "-X 'main.Version={{ .Version }}'"
- "-s"
- "-w"
- id: kadabra
binary: kadabra
@ -47,9 +49,13 @@ builds:
- 5
- 6
- 7
gcflags:
- "all=-l -B"
ldflags:
- "-X 'main.Commit={{ .Commit }}'"
- "-X 'main.Version={{ .Version }}'"
- "-s"
- "-w"
checksum:
name_template: "checksums.txt"

View File

@ -7,6 +7,7 @@
- cassowary
- codegod100
- decentral1se
- fauno
- frando
- kawaiipunk
- knoflook

View File

@ -1,23 +1,29 @@
# Build image
FROM golang:1.21-alpine AS build
ENV GOPRIVATE coopcloud.tech
RUN apk add --no-cache \
ca-certificates \
gcc \
git \
make \
musl-dev
RUN update-ca-certificates
COPY . /app
WORKDIR /app
RUN CGO_ENABLED=0 make build
FROM scratch
# Release image ("slim")
FROM alpine:3.19.1
RUN apk add --no-cache \
ca-certificates \
git \
openssh
RUN update-ca-certificates
COPY --from=build /app/abra /abra

View File

@ -5,6 +5,7 @@ GOPATH := $(shell go env GOPATH)
GOVERSION := 1.21
LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
GCFLAGS := "all=-l -B"
export GOPRIVATE=coopcloud.tech
@ -12,22 +13,24 @@ export GOPRIVATE=coopcloud.tech
all: format check build-abra test
run-abra:
@go run -ldflags=$(LDFLAGS) $(ABRA)
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
run-kadabra:
@go run -ldflags=$(LDFLAGS) $(KADABRA)
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
install-abra:
@go install -ldflags=$(LDFLAGS) $(ABRA)
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
install-kadabra:
@go install -ldflags=$(LDFLAGS) $(KADABRA)
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
install: install-abra install-kadabra
build-abra:
@go build -v -ldflags=$(DIST_LDFLAGS) $(ABRA)
@go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(ABRA)
build-kadabra:
@go build -v -ldflags=$(DIST_LDFLAGS) $(KADABRA)
@go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(KADABRA)
build: build-abra build-kadabra
@ -42,10 +45,10 @@ clean:
@rm '$(GOPATH)/bin/kadabra'
format:
@gofmt -s -w .
@gofmt -s -w $$(find . -type f -name '*.go' | grep -v "/vendor/")
check:
@test -z $$(gofmt -l .) || \
@test -z $$(gofmt -l $$(find . -type f -name '*.go' | grep -v "/vendor/")) || \
(echo "gofmt: formatting issue - run 'make format' to resolve" && exit 1)
test:
@ -53,3 +56,6 @@ test:
loc:
@find . -name "*.go" | xargs wc -l
deps:
@go get -t -u ./...

View File

@ -1,37 +1,34 @@
package app
import (
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var AppCommand = cli.Command{
Name: "app",
Aliases: []string{"a"},
Usage: "Manage apps",
ArgsUsage: "<domain>",
Description: "Functionality for managing the life cycle of your apps",
Subcommands: []cli.Command{
appBackupCommand,
appCheckCommand,
appCmdCommand,
appConfigCommand,
appCpCommand,
appDeployCommand,
appErrorsCommand,
appListCommand,
appLogsCommand,
appNewCommand,
appPsCommand,
appRemoveCommand,
appRestartCommand,
appRestoreCommand,
appRollbackCommand,
appRunCommand,
appSecretCommand,
appServicesCommand,
appUndeployCommand,
appUpgradeCommand,
appVersionCommand,
appVolumeCommand,
Name: "app",
Aliases: []string{"a"},
Usage: "Manage apps",
UsageText: "abra app [command] [arguments] [options]",
Commands: []*cli.Command{
&appBackupCommand,
&appCheckCommand,
&appCmdCommand,
&appConfigCommand,
&appCpCommand,
&appDeployCommand,
&appListCommand,
&appLogsCommand,
&appNewCommand,
&appPsCommand,
&appRemoveCommand,
&appRestartCommand,
&appRestoreCommand,
&appRollbackCommand,
&appRunCommand,
&appSecretCommand,
&appServicesCommand,
&appUndeployCommand,
&appUpgradeCommand,
&appVolumeCommand,
},
}

View File

@ -1,414 +1,283 @@
package app
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"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"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/klauspost/pgzip"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
type backupConfig struct {
preHookCmd string
postHookCmd string
backupPaths []string
var snapshot string
var snapshotFlag = &cli.StringFlag{
Name: "snapshot",
Aliases: []string{"s"},
Usage: "Lists specific snapshot",
Destination: &snapshot,
}
var appBackupCommand = cli.Command{
Name: "backup",
Aliases: []string{"bk"},
Usage: "Run app backup",
ArgsUsage: "<domain> [<service>]",
var includePath string
var includePathFlag = &cli.StringFlag{
Name: "path",
Aliases: []string{"p"},
Usage: "Include path",
Destination: &includePath,
}
var resticRepo string
var resticRepoFlag = &cli.StringFlag{
Name: "repo",
Aliases: []string{"r"},
Usage: "Restic repository",
Destination: &resticRepo,
}
var appBackupListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
snapshotFlag,
includePathFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Description: `
Run an app backup.
Before: internal.SubCommandBefore,
Usage: "List all backups",
UsageText: "abra app backup list <domain> [options]",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
A backup command and pre/post hook commands are defined in the recipe
configuration. Abra reads this configuration and run the comands in the context
of the deployed services. Pass <service> if you only want to back up a single
service. All backups are placed in the ~/.abra/backups directory.
A single backup file is produced for all backup paths specified for a service.
If we have the following backup configuration:
- "backupbot.backup.path=/var/lib/foo,/var/lib/bar"
And we run "abra app backup example.com app", Abra will produce a file that
looks like:
~/.abra/backups/example_com_app_609341138.tar.gz
This file is a compressed archive which contains all backup paths. To see paths, run:
tar -tf ~/.abra/backups/example_com_app_609341138.tar.gz
(Make sure to change the name of the backup file)
This single file can be used to restore your app. See "abra app restore" for more.
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
recipe, err := recipePkg.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
backupConfigs := make(map[string]backupConfig)
for _, service := range recipe.Config.Services {
if backupsEnabled, ok := service.Deploy.Labels["backupbot.backup"]; ok {
if backupsEnabled == "true" {
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name)
bkConfig := backupConfig{}
logrus.Debugf("backup config detected for %s", fullServiceName)
if paths, ok := service.Deploy.Labels["backupbot.backup.path"]; ok {
logrus.Debugf("detected backup paths for %s: %s", fullServiceName, paths)
bkConfig.backupPaths = strings.Split(paths, ",")
}
if preHookCmd, ok := service.Deploy.Labels["backupbot.backup.pre-hook"]; ok {
logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd)
bkConfig.preHookCmd = preHookCmd
}
if postHookCmd, ok := service.Deploy.Labels["backupbot.backup.post-hook"]; ok {
logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd)
bkConfig.postHookCmd = postHookCmd
}
backupConfigs[service.Name] = bkConfig
}
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
serviceName := c.Args().Get(1)
if serviceName != "" {
backupConfig, ok := backupConfigs[serviceName]
if !ok {
logrus.Fatalf("no backup config for %s? does %s exist?", serviceName, serviceName)
}
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
if err != nil {
log.Fatal(err)
}
logrus.Infof("running backup for the %s service", serviceName)
execEnv := []string{fmt.Sprintf("SERVICE=%s", app.Domain)}
if snapshot != "" {
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
}
if includePath != "" {
log.Debugf("including INCLUDE_PATH=%s in backupbot exec invocation", includePath)
execEnv = append(execEnv, fmt.Sprintf("INCLUDE_PATH=%s", includePath))
}
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err)
}
} else {
if len(backupConfigs) == 0 {
logrus.Fatalf("no backup configs discovered for %s?", app.Name)
}
for serviceName, backupConfig := range backupConfigs {
logrus.Infof("running backup for the %s service", serviceName)
if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
logrus.Fatal(err)
}
}
if err := internal.RunBackupCmdRemote(cl, "ls", targetContainer.ID, execEnv); err != nil {
log.Fatal(err)
}
return nil
},
}
// TimeStamp generates a file name friendly timestamp.
func TimeStamp() string {
ts := time.Now().UTC().Format(time.RFC3339)
return strings.Replace(ts, ":", "-", -1)
}
var appBackupDownloadCommand = cli.Command{
Name: "download",
Aliases: []string{"d"},
Flags: []cli.Flag{
snapshotFlag,
includePathFlag,
},
Before: internal.SubCommandBefore,
Usage: "Download a backup",
UsageText: "abra app backup download <domain> [options]",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
// runBackup does the actual backup logic.
func runBackup(cl *dockerClient.Client, app config.App, serviceName string, bkConfig backupConfig) error {
if len(bkConfig.backupPaths) == 0 {
return fmt.Errorf("backup paths are empty for %s?", serviceName)
}
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
return err
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
if err != nil {
return err
}
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
if bkConfig.preHookCmd != "" {
splitCmd := internal.SafeSplit(bkConfig.preHookCmd)
logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd)
preHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil {
return fmt.Errorf("failed to run %s on %s: %s", bkConfig.preHookCmd, targetContainer.ID, err.Error())
if !internal.Chaos {
if err := app.Recipe.EnsureIsClean(); err != nil {
log.Fatal(err)
}
if !internal.Offline {
if err := app.Recipe.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
if err := app.Recipe.EnsureLatest(); err != nil {
log.Fatal(err)
}
}
logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, bkConfig.preHookCmd)
}
var tempBackupPaths []string
for _, remoteBackupPath := range bkConfig.backupPaths {
sanitisedPath := strings.ReplaceAll(remoteBackupPath, "/", "_")
localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s%s_%s.tar.gz", fullServiceName, sanitisedPath, TimeStamp()))
logrus.Debugf("temporarily backing up %s:%s to %s", fullServiceName, remoteBackupPath, localBackupPath)
logrus.Infof("backing up %s:%s", fullServiceName, remoteBackupPath)
content, _, err := cl.CopyFromContainer(context.Background(), targetContainer.ID, remoteBackupPath)
cl, err := client.New(app.Server)
if err != nil {
logrus.Debugf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
log.Fatal(err)
}
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
if err != nil {
log.Fatal(err)
}
execEnv := []string{fmt.Sprintf("SERVICE=%s", app.Domain)}
if snapshot != "" {
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
}
if includePath != "" {
log.Debugf("including INCLUDE_PATH=%s in backupbot exec invocation", includePath)
execEnv = append(execEnv, fmt.Sprintf("INCLUDE_PATH=%s", includePath))
}
if err := internal.RunBackupCmdRemote(cl, "download", targetContainer.ID, execEnv); err != nil {
log.Fatal(err)
}
remoteBackupDir := "/tmp/backup.tar.gz"
currentWorkingDir := "."
if err = CopyFromContainer(cl, targetContainer.ID, remoteBackupDir, currentWorkingDir); err != nil {
log.Fatal(err)
}
fmt.Println("backup successfully downloaded to current working directory")
return nil
},
}
var appBackupCreateCommand = cli.Command{
Name: "create",
Aliases: []string{"c"},
Flags: []cli.Flag{
resticRepoFlag,
},
Before: internal.SubCommandBefore,
Usage: "Create a new backup",
UsageText: "abra app backup create <domain> [options]",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := app.Recipe.EnsureIsClean(); err != nil {
log.Fatal(err)
}
return fmt.Errorf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
}
defer content.Close()
_, srcBase := archive.SplitPathDirEntry(remoteBackupPath)
preArchive := archive.RebaseArchiveEntries(content, srcBase, remoteBackupPath)
if err := copyToFile(localBackupPath, preArchive); err != nil {
logrus.Debugf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
if !internal.Offline {
if err := app.Recipe.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
return fmt.Errorf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
}
tempBackupPaths = append(tempBackupPaths, localBackupPath)
}
logrus.Infof("compressing and merging archives...")
if err := mergeArchives(tempBackupPaths, fullServiceName); err != nil {
logrus.Debugf("failed to merge archive files: %s", err.Error())
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
return fmt.Errorf("failed to merge archive files: %s", err.Error())
}
if err := cleanupTempArchives(tempBackupPaths); err != nil {
return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
}
if bkConfig.postHookCmd != "" {
splitCmd := internal.SafeSplit(bkConfig.postHookCmd)
logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd)
postHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, bkConfig.postHookCmd)
}
return nil
}
func copyToFile(outfile string, r io.Reader) error {
tmpFile, err := os.CreateTemp(filepath.Dir(outfile), ".tar_temp")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
_, err = io.Copy(tmpFile, r)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
if err = os.Rename(tmpPath, outfile); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
func cleanupTempArchives(tarPaths []string) error {
for _, tarPath := range tarPaths {
if err := os.RemoveAll(tarPath); err != nil {
return err
}
logrus.Debugf("remove temporary archive file %s", tarPath)
}
return nil
}
func mergeArchives(tarPaths []string, serviceName string) error {
var out io.Writer
var cout *pgzip.Writer
localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s_%s.tar.gz", serviceName, TimeStamp()))
fout, err := os.Create(localBackupPath)
if err != nil {
return fmt.Errorf("Failed to open %s: %s", localBackupPath, err)
}
defer fout.Close()
out = fout
cout = pgzip.NewWriter(out)
out = cout
tw := tar.NewWriter(out)
for _, tarPath := range tarPaths {
if err := addTar(tw, tarPath); err != nil {
return fmt.Errorf("failed to merge %s: %v", tarPath, err)
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("failed to close tar writer %v", err)
}
if cout != nil {
if err := cout.Flush(); err != nil {
return fmt.Errorf("failed to flush: %s", err)
} else if err = cout.Close(); err != nil {
return fmt.Errorf("failed to close compressed writer: %s", err)
}
}
logrus.Infof("backed up %s to %s", serviceName, localBackupPath)
return nil
}
func addTar(tw *tar.Writer, pth string) (err error) {
var tr *tar.Reader
var rc io.ReadCloser
var hdr *tar.Header
if tr, rc, err = openTarFile(pth); err != nil {
return
}
for {
if hdr, err = tr.Next(); err != nil {
if err == io.EOF {
err = nil
if err := app.Recipe.EnsureLatest(); err != nil {
log.Fatal(err)
}
break
}
if err = tw.WriteHeader(hdr); err != nil {
break
} else if _, err = io.Copy(tw, tr); err != nil {
break
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
}
}
if err == nil {
err = rc.Close()
} else {
rc.Close()
}
return
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
if err != nil {
log.Fatal(err)
}
execEnv := []string{fmt.Sprintf("SERVICE=%s", app.Domain)}
if resticRepo != "" {
log.Debugf("including RESTIC_REPO=%s in backupbot exec invocation", resticRepo)
execEnv = append(execEnv, fmt.Sprintf("RESTIC_REPO=%s", resticRepo))
}
if err := internal.RunBackupCmdRemote(cl, "create", targetContainer.ID, execEnv); err != nil {
log.Fatal(err)
}
return nil
},
}
func openTarFile(pth string) (tr *tar.Reader, rc io.ReadCloser, err error) {
var fin *os.File
var n int
buff := make([]byte, 1024)
var appBackupSnapshotsCommand = cli.Command{
Name: "snapshots",
Aliases: []string{"s"},
Flags: []cli.Flag{
snapshotFlag,
},
Before: internal.SubCommandBefore,
Usage: "List backup snapshots",
UsageText: "abra app backup snapshots <domain> [options]",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if fin, err = os.Open(pth); err != nil {
return
}
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
}
if n, err = fin.Read(buff); err != nil {
fin.Close()
return
} else if n == 0 {
fin.Close()
err = fmt.Errorf("%s is empty", pth)
return
}
if !internal.Chaos {
if err := app.Recipe.EnsureIsClean(); err != nil {
log.Fatal(err)
}
if _, err = fin.Seek(0, 0); err != nil {
fin.Close()
return
}
if !internal.Offline {
if err := app.Recipe.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
rc = fin
tr = tar.NewReader(rc)
if err := app.Recipe.EnsureLatest(); err != nil {
log.Fatal(err)
}
}
return tr, rc, nil
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
}
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
if err != nil {
log.Fatal(err)
}
execEnv := []string{fmt.Sprintf("SERVICE=%s", app.Domain)}
if snapshot != "" {
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
}
if err := internal.RunBackupCmdRemote(cl, "snapshots", targetContainer.ID, execEnv); err != nil {
log.Fatal(err)
}
return nil
},
}
var appBackupCommand = cli.Command{
Name: "backup",
Aliases: []string{"b"},
Usage: "Manage app backups",
UsageText: "abra app backup [command] [arguments] [options]",
Commands: []*cli.Command{
&appBackupListCommand,
&appBackupSnapshotsCommand,
&appBackupDownloadCommand,
&appBackupCreateCommand,
},
}

View File

@ -1,23 +1,24 @@
package app
import (
"context"
"fmt"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/charmbracelet/lipgloss"
"github.com/urfave/cli/v3"
)
var appCheckCommand = cli.Command{
Name: "check",
Aliases: []string{"chk"},
Usage: "Ensure an app is well configured",
Description: `
This command compares env vars in both the app ".env" and recipe ".env.sample"
file.
Name: "check",
Aliases: []string{"chk"},
UsageText: "abra app check <domain> [options]",
Usage: "Ensure an app is well configured",
Description: `Compare env vars in both the app ".env" and recipe ".env.sample" file.
The goal is to ensure that recipe ".env.sample" env vars are defined in your
app ".env" file. Only env var definitions in the ".env.sample" which are
@ -27,54 +28,52 @@ these env vars, then "check" will complain.
Recipe maintainers may or may not provide defaults for env vars within their
recipes regardless of commenting or not (e.g. through the use of
${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.ChaosFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
tableCol := []string{"recipe env sample", "app env"}
table := formatter.CreateTable(tableCol)
envVars, err := config.CheckEnv(app)
table, err := formatter.CreateTable()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
table.
Headers("RECIPE ENV SAMPLE", "APP ENV").
StyleFunc(func(row, col int) lipgloss.Style {
switch {
case col == 1:
return lipgloss.NewStyle().Padding(0, 1, 0, 1).Align(lipgloss.Center)
default:
return lipgloss.NewStyle().Padding(0, 1, 0, 1)
}
})
envVars, err := appPkg.CheckEnv(app)
if err != nil {
log.Fatal(err)
}
for _, envVar := range envVars {
if envVar.Present {
table.Append([]string{envVar.Name, "✅"})
val := []string{envVar.Name, "✅"}
table.Row(val...)
} else {
table.Append([]string{envVar.Name, "❌"})
val := []string{envVar.Name, "❌"}
table.Row(val...)
}
}
table.Render()
fmt.Println(table)
return nil
},

View File

@ -1,110 +1,85 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path"
"sort"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/app"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
var appCmdCommand = cli.Command{
Name: "command",
Aliases: []string{"cmd"},
Usage: "Run app commands",
Name: "command",
Aliases: []string{"cmd"},
Usage: "Run app commands",
UsageText: "abra app cmd <domain> [<service>] <cmd> [<cmd-args>] [options]",
Description: `Run an app specific command.
These commands are bash functions, defined in the abra.sh of the recipe itself.
They can be run within the context of a service (e.g. app) or locally on your
work station by passing "--local". Arguments can be passed into these functions
using the "-- <args>" syntax.
Example:
abra app cmd example.com app create_user -- me@example.com
`,
ArgsUsage: "<domain> [<service>] <command> [-- <args>]",
work station by passing "--local".`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.LocalCmdFlag,
internal.RemoteUserFlag,
internal.TtyFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
Subcommands: []cli.Command{appCmdListCommand},
BashComplete: func(ctx *cli.Context) {
args := ctx.Args()
switch len(args) {
Before: internal.SubCommandBefore,
Commands: []*cli.Command{
&appCmdListCommand,
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
args := cmd.Args()
switch args.Len() {
case 0:
autocomplete.AppNameComplete(ctx)
autocomplete.AppNameComplete(ctx, cmd)
case 1:
autocomplete.ServiceNameComplete(args.Get(0))
case 2:
cmdNameComplete(args.Get(0))
}
},
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if internal.LocalCmd && internal.RemoteUser != "" {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use --local & --user together"))
internal.ShowSubcommandHelpAndError(cmd, errors.New("cannot use --local & --user together"))
}
hasCmdArgs, parsedCmdArgs := parseCmdArgs(c.Args(), internal.LocalCmd)
hasCmdArgs, parsedCmdArgs := parseCmdArgs(cmd.Args().Slice(), internal.LocalCmd)
abraSh := path.Join(config.RECIPES_DIR, app.Recipe, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if _, err := os.Stat(app.Recipe.AbraShPath); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist for %s?", abraSh, app.Name)
log.Fatalf("%s does not exist for %s?", app.Recipe.AbraShPath, app.Name)
}
logrus.Fatal(err)
log.Fatal(err)
}
if internal.LocalCmd {
if !(len(c.Args()) >= 2) {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments"))
if !(cmd.Args().Len() >= 2) {
internal.ShowSubcommandHelpAndError(cmd, errors.New("missing arguments"))
}
cmdName := c.Args().Get(1)
if err := internal.EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
logrus.Fatal(err)
cmdName := cmd.Args().Get(1)
if err := internal.EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
log.Fatal(err)
}
logrus.Debugf("--local detected, running %s on local work station", cmdName)
log.Debugf("--local detected, running %s on local work station", cmdName)
var exportEnv string
for k, v := range app.Env {
@ -113,38 +88,38 @@ Example:
var sourceAndExec string
if hasCmdArgs {
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s %s", app.Name, app.StackName(), exportEnv, abraSh, cmdName, parsedCmdArgs)
log.Debugf("parsed following command arguments: %s", parsedCmdArgs)
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s %s", app.Name, app.StackName(), exportEnv, app.Recipe.AbraShPath, cmdName, parsedCmdArgs)
} else {
logrus.Debug("did not detect any command arguments")
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s", app.Name, app.StackName(), exportEnv, abraSh, cmdName)
log.Debug("did not detect any command arguments")
sourceAndExec = fmt.Sprintf("TARGET=local; APP_NAME=%s; STACK_NAME=%s; %s . %s; %s", app.Name, app.StackName(), exportEnv, app.Recipe.AbraShPath, cmdName)
}
shell := "/bin/bash"
if _, err := os.Stat(shell); errors.Is(err, os.ErrNotExist) {
logrus.Debugf("%s does not exist locally, use /bin/sh as fallback", shell)
log.Debugf("%s does not exist locally, use /bin/sh as fallback", shell)
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", sourceAndExec)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
} else {
if !(len(c.Args()) >= 3) {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments"))
if !(cmd.Args().Len() >= 3) {
internal.ShowSubcommandHelpAndError(cmd, errors.New("missing arguments"))
}
targetServiceName := c.Args().Get(1)
targetServiceName := cmd.Args().Get(1)
cmdName := c.Args().Get(2)
if err := internal.EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
logrus.Fatal(err)
cmdName := cmd.Args().Get(2)
if err := internal.EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
log.Fatal(err)
}
serviceNames, err := config.GetAppServiceNames(app.Name)
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
matchingServiceName := false
@ -155,24 +130,24 @@ Example:
}
if !matchingServiceName {
logrus.Fatalf("no service %s for %s?", targetServiceName, app.Name)
log.Fatalf("no service %s for %s?", targetServiceName, app.Name)
}
logrus.Debugf("running command %s within the context of %s_%s", cmdName, app.StackName(), targetServiceName)
log.Debugf("running command %s within the context of %s_%s", cmdName, app.StackName(), targetServiceName)
if hasCmdArgs {
logrus.Debugf("parsed following command arguments: %s", parsedCmdArgs)
log.Debugf("parsed following command arguments: %s", parsedCmdArgs)
} else {
logrus.Debug("did not detect any command arguments")
log.Debug("did not detect any command arguments")
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := internal.RunCmdRemote(cl, app, abraSh, targetServiceName, cmdName, parsedCmdArgs); err != nil {
logrus.Fatal(err)
if err := internal.RunCmdRemote(cl, app, app.Recipe.AbraShPath, targetServiceName, cmdName, parsedCmdArgs); err != nil {
log.Fatal(err)
}
}
@ -217,40 +192,39 @@ var appCmdListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List all available commands",
ArgsUsage: "<domain>",
UsageText: "abra app cmd ls <domain> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
ShellComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.EnsureIsClean(); err != nil {
log.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.EnsureLatest(); err != nil {
log.Fatal(err)
}
}
cmdNames, err := getShCmdNames(app)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, cmdName := range cmdNames {
@ -261,9 +235,8 @@ var appCmdListCommand = cli.Command{
},
}
func getShCmdNames(app config.App) ([]string, error) {
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
cmdNames, err := config.ReadAbraShCmdNames(abraShPath)
func getShCmdNames(app appPkg.App) ([]string, error) {
cmdNames, err := appPkg.ReadAbraShCmdNames(app.Recipe.AbraShPath)
if err != nil {
return nil, err
}

View File

@ -1,62 +1,61 @@
package app
import (
"context"
"errors"
"os"
"os/exec"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appConfigCommand = cli.Command{
Name: "config",
Aliases: []string{"cfg"},
Usage: "Edit app config",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
appName := c.Args().First()
Name: "config",
Aliases: []string{"cfg"},
Usage: "Edit app config",
UsageText: "abra app config <domain> [options]",
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
appName := cmd.Args().First()
if appName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no app provided"))
internal.ShowSubcommandHelpAndError(cmd, errors.New("no app provided"))
}
files, err := config.LoadAppFiles("")
files, err := appPkg.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
appFile, exists := files[appName]
if !exists {
logrus.Fatalf("cannot find app with name %s", appName)
log.Fatalf("cannot find app with name %s", appName)
}
ed, ok := os.LookupEnv("EDITOR")
if !ok {
edPrompt := &survey.Select{
Message: "Which editor do you wish to use?",
Message: "which editor do you wish to use?",
Options: []string{"vi", "vim", "nvim", "nano", "pico", "emacs"},
}
if err := survey.AskOne(edPrompt, &ed); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
cmd := exec.Command(ed, appFile.Path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logrus.Fatal(err)
c := exec.Command(ed, appFile.Path)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
log.Fatal(err)
}
return nil

View File

@ -15,28 +15,23 @@ import (
"coopcloud.tech/abra/pkg/client"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appCpCommand = cli.Command{
Name: "cp",
Aliases: []string{"c"},
ArgsUsage: "<domain> <src> <dst>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "Copy files to/from a deployed app service",
Description: `
Copy files to and from any app service file system.
Before: internal.SubCommandBefore,
Usage: "Copy files to/from a deployed app service",
UsageText: "abra app cp <domain> <src> <dst> [options]",
Description: `Copy files to and from any app service file system.
If you want to copy a myfile.txt to the root of the app service:
@ -44,44 +39,47 @@ If you want to copy a myfile.txt to the root of the app service:
And if you want to copy that file back to your current working directory locally:
abra app cp <domain> app:/myfile.txt .
`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
abra app cp <domain> app:/myfile.txt`,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
src := c.Args().Get(1)
dst := c.Args().Get(2)
src := cmd.Args().Get(1)
dst := cmd.Args().Get(2)
if src == "" {
logrus.Fatal("missing <src> argument")
log.Fatal("missing <src> argument")
}
if dst == "" {
logrus.Fatal("missing <dest> argument")
log.Fatal("missing <dest> argument")
}
srcPath, dstPath, service, toContainer, err := parseSrcAndDst(src, dst)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
container, err := containerPkg.GetContainerFromStackAndService(cl, app.StackName(), service)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
if toContainer {
err = copyToContainer(cl, container.ID, srcPath, dstPath)
err = CopyToContainer(cl, container.ID, srcPath, dstPath)
} else {
err = copyFromContainer(cl, container.ID, srcPath, dstPath)
err = CopyFromContainer(cl, container.ID, srcPath, dstPath)
}
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil
@ -106,9 +104,9 @@ func parseSrcAndDst(src, dst string) (srcPath string, dstPath string, service st
return "", "", "", false, errServiceMissing
}
// copyToContainer copies a file or directory from the local file system to the container.
// CopyToContainer copies a file or directory from the local file system to the container.
// See the possible copy modes and their documentation.
func copyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
srcStat, err := os.Stat(srcPath)
if err != nil {
return fmt.Errorf("local %s ", err)
@ -140,7 +138,7 @@ func copyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
if err != nil {
return err
}
if err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
@ -167,7 +165,7 @@ func copyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
return err
}
logrus.Debugf("copy %s from local to %s on container", srcPath, dstPath)
log.Debugf("copy %s from local to %s on container", srcPath, dstPath)
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), containerID, dstPath, content, copyOpts); err != nil {
return err
@ -179,7 +177,7 @@ func copyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
if err != nil {
return err
}
if err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
@ -194,9 +192,9 @@ func copyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
return nil
}
// copyFromContainer copies a file or directory from the given container to the local file system.
// CopyFromContainer copies a file or directory from the given container to the local file system.
// See the possible copy modes and their documentation.
func copyFromContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
func CopyFromContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
srcStat, err := cl.ContainerStatPath(context.Background(), containerID, srcPath)
if err != nil {
if errdefs.IsNotFound(err) {

View File

@ -6,185 +6,181 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/secret"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appDeployCommand = cli.Command{
Name: "deploy",
Aliases: []string{"d"},
Usage: "Deploy an app",
ArgsUsage: "<domain> [<version>]",
UsageText: "abra app deploy <domain> [<version>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag,
internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
Deploy an app. It does not support incrementing the version of a deployed app,
for this you need to look at the "abra app upgrade <domain>" command.
Description: `Deploy an app.
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.
This command supports chaos operations. Use "--chaos" to deploy your recipe
checkout as-is. Recipe commit hashes are also supported values for
"[<version>]". Please note, "upgrade"/"rollback" do not support chaos
operations.`,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
var warnMessages []string
Chaos mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
app := internal.ValidateApp(cmd)
stackName := app.StackName()
specificVersion := c.Args().Get(1)
specificVersion := cmd.Args().Get(1)
if specificVersion == "" {
specificVersion = app.Recipe.Version
}
if specificVersion != "" && internal.Chaos {
logrus.Fatal("cannot use <version> and --chaos together")
log.Fatal("cannot use <version> and --chaos together")
}
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
if specificVersion == "" && app.Recipe.Version != "" && !internal.Chaos {
log.Debugf("retrieved %s as version from env file", app.Recipe.Version)
specificVersion = app.Recipe.Version
}
r, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
if err := lint.LintForErrors(app.Recipe); err != nil {
log.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
log.Debugf("checking whether %s is already deployed", stackName)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
// NOTE(d1): handles "<version> as git hash" use case
var isChaosCommit bool
// NOTE(d1): check out specific version before dealing with secrets. This
// is because we need to deal with GetComposeFiles under the hood and these
// files change from version to version which therefore affects which
// secrets might be generated
version := deployMeta.Version
if specificVersion != "" {
version = specificVersion
log.Debugf("choosing %s as version to deploy", version)
var err error
isChaosCommit, err = app.Recipe.EnsureVersion(version)
if err != nil {
log.Fatal(err)
}
if isChaosCommit {
log.Debugf("assuming '%s' is a chaos commit", version)
internal.Chaos = true
}
}
secStats, err := secret.PollSecretsStatus(cl, app)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, secStat := range secStats {
if !secStat.CreatedOnRemote {
logrus.Fatalf("unable to deploy, secrets not generated (%s)?", secStat.LocalName)
log.Fatalf("unable to deploy, secrets not generated (%s)?", secStat.LocalName)
}
}
if isDeployed {
if deployMeta.IsDeployed {
if internal.Force || internal.Chaos {
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name)
warnMessages = append(warnMessages, fmt.Sprintf("%s is already deployed", app.Name))
} else {
logrus.Fatalf("%s is already deployed", app.Name)
}
}
version := deployedVersion
if specificVersion != "" {
version = specificVersion
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
log.Fatalf("%s is already deployed", app.Name)
}
}
if !internal.Chaos && specificVersion == "" {
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
versions, err := app.Recipe.Tags()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil {
logrus.Warn(err)
}
for _, recipeVersion := range recipeVersions {
for version := range recipeVersion {
versions = append(versions, version)
}
}
log.Fatal(err)
}
if len(versions) > 0 && !internal.Chaos {
version = versions[len(versions)-1]
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Recipe, version); err != nil {
logrus.Fatal(err)
log.Debugf("choosing %s as version to deploy", version)
if _, err := app.Recipe.EnsureVersion(version); err != nil {
log.Fatal(err)
}
} else {
head, err := git.GetRecipeHead(app.Recipe)
head, err := app.Recipe.Head()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
version = formatter.SmallSHA(head.String())
logrus.Warn("no versions detected, using latest commit")
warnMessages = append(warnMessages, fmt.Sprintf("no versions detected, using latest commit"))
}
}
chaosVersion := config.CHAOS_DEFAULT
if internal.Chaos {
logrus.Warnf("chaos mode engaged")
var err error
version, err = recipe.ChaosVersion(app.Recipe)
if err != nil {
logrus.Fatal(err)
warnMessages = append(warnMessages, "chaos mode engaged")
if isChaosCommit {
chaosVersion = specificVersion
versionLabelLocal, err := app.Recipe.GetVersionLabelLocal()
if err != nil {
log.Fatal(err)
}
version = versionLabelLocal
} else {
var err error
chaosVersion, err = app.Recipe.ChaosVersion()
if err != nil {
log.Fatal(err)
}
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
deployOpts := stack.Deploy{
@ -192,63 +188,76 @@ recipes.
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, version)
config.SetUpdateLabel(compose, stackName, app.Env)
appPkg.ExposeAllEnv(stackName, compose, app.Env)
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
appPkg.SetChaosVersionLabel(compose, stackName, chaosVersion)
appPkg.SetUpdateLabel(compose, stackName, app.Env)
envVars, err := config.CheckEnv(app)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, envVar := range envVars {
if !envVar.Present {
logrus.Warnf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain)
warnMessages = append(warnMessages,
fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
)
}
}
if err := internal.DeployOverview(app, version, "continue with deployment?"); err != nil {
logrus.Fatal(err)
}
if !internal.NoDomainChecks {
domainName, ok := app.Env["DOMAIN"]
if ok {
if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
} else {
logrus.Warn("skipping domain checks as no DOMAIN=... configured for app")
warnMessages = append(warnMessages, "skipping domain checks as no DOMAIN=... configured for app")
}
} else {
logrus.Warn("skipping domain checks as requested")
warnMessages = append(warnMessages, "skipping domain checks as requested")
}
stack.WaitTimeout, err = config.GetTimeoutFromLabel(compose, stackName)
if err != nil {
logrus.Fatal(err)
if err := internal.DeployOverview(app, warnMessages, version, chaosVersion); err != nil {
log.Fatal(err)
}
logrus.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
if err != nil {
log.Fatal(err)
}
log.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, internal.DontWaitConverge); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
postDeployCmds, ok := app.Env["POST_DEPLOY_CMDS"]
if ok && !internal.DontWaitConverge {
logrus.Debugf("run the following post-deploy commands: %s", postDeployCmds)
log.Debugf("run the following post-deploy commands: %s", postDeployCmds)
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
logrus.Fatalf("attempting to run post deploy commands, saw: %s", err)
log.Fatalf("attempting to run post deploy commands, saw: %s", err)
}
}
app.Recipe.Version = version
if chaosVersion != config.CHAOS_DEFAULT {
app.Recipe.Version = chaosVersion
}
log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err)
}
return nil
},
}

View File

@ -1,142 +0,0 @@
package app
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
var appErrorsCommand = cli.Command{
Name: "errors",
Usage: "List errors for a deployed app",
ArgsUsage: "<domain>",
Description: `
List errors for a deployed app.
This is a best-effort implementation and an attempt to gather a number of tips
& tricks for finding errors together into one convenient command. When an app
is failing to deploy or having issues, it could be a lot of things.
This command currently takes into account:
Is the service deployed?
Is the service killed by an OOM error?
Is the service reporting an error (like in "ps --no-trunc" output)
Is the service healthcheck failing? what are the healthcheck logs?
Got any more ideas? Please let us know:
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
This command is best accompanied by "abra app logs <domain>" which may reveal
further information which can help you debug the cause of an app failure via
the logs.
`,
Aliases: []string{"e"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.WatchFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
if !internal.Watch {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
return nil
}
for {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
time.Sleep(2 * time.Second)
}
},
}
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
recipe, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
return err
}
for _, service := range recipe.Config.Services {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
if err != nil {
return err
}
if len(containers) == 0 {
logrus.Warnf("%s is not up, something seems wrong", service.Name)
continue
}
container := containers[0]
containerState, err := cl.ContainerInspect(context.Background(), container.ID)
if err != nil {
logrus.Fatal(err)
}
if containerState.State.OOMKilled {
logrus.Warnf("%s has been killed due to an out of memory error", service.Name)
}
if containerState.State.Error != "" {
logrus.Warnf("%s reports this error: %s", service.Name, containerState.State.Error)
}
if containerState.State.Health != nil {
if containerState.State.Health.Status != "healthy" {
logrus.Warnf("%s healthcheck status is %s", service.Name, containerState.State.Health.Status)
logrus.Warnf("%s healthcheck has failed %s times", service.Name, strconv.Itoa(containerState.State.Health.FailingStreak))
for _, log := range containerState.State.Health.Log {
logrus.Warnf("%s healthcheck logs: %s", service.Name, strings.TrimSpace(log.Output))
}
}
}
}
return nil
}
func getServiceName(names []string) string {
containerName := strings.Join(names, " ")
trimmed := strings.TrimPrefix(containerName, "/")
return strings.Split(trimmed, ".")[0]
}

View File

@ -1,6 +1,7 @@
package app
import (
"context"
"encoding/json"
"fmt"
"sort"
@ -8,36 +9,44 @@ import (
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/tagcmp"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var status bool
var statusFlag = &cli.BoolFlag{
Name: "status, S",
Usage: "Show app deployment status",
Destination: &status,
}
var (
status bool
statusFlag = &cli.BoolFlag{
Name: "status",
Aliases: []string{"S"},
Usage: "Show app deployment status",
Destination: &status,
}
)
var recipeFilter string
var recipeFlag = &cli.StringFlag{
Name: "recipe, r",
Value: "",
Usage: "Show apps of a specific recipe",
Destination: &recipeFilter,
}
var (
recipeFilter string
recipeFlag = &cli.StringFlag{
Name: "recipe",
Aliases: []string{"r"},
Value: "",
Usage: "Show apps of a specific recipe",
Destination: &recipeFilter,
}
)
var listAppServer string
var listAppServerFlag = &cli.StringFlag{
Name: "server, s",
Value: "",
Usage: "Show apps of a specific server",
Destination: &listAppServer,
}
var (
listAppServer string
listAppServerFlag = &cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &listAppServer,
}
)
type appStatus struct {
Server string `json:"server"`
@ -62,41 +71,37 @@ type serverStatus struct {
}
var appListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List all managed apps",
Description: `
Read the local file system listing of apps and servers (e.g. ~/.abra/) to
generate a report of all your apps.
Name: "list",
Aliases: []string{"ls"},
Usage: "List all managed apps",
UsageText: "abra app list [options]",
Description: `Generate a report of all managed apps.
By passing the "--status/-S" flag, you can query all your servers for the
actual live deployment status. Depending on how many servers you manage, this
can take some time.
`,
can take some time.`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.MachineReadableFlag,
statusFlag,
listAppServerFlag,
recipeFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
appFiles, err := config.LoadAppFiles(listAppServer)
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
appFiles, err := appPkg.LoadAppFiles(listAppServer)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
apps, err := config.GetApps(appFiles, recipeFilter)
apps, err := appPkg.GetApps(appFiles, recipeFilter)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
sort.Sort(config.ByServerAndRecipe(apps))
sort.Sort(appPkg.ByServerAndRecipe(apps))
statuses := make(map[string]map[string]string)
var catl recipe.RecipeCatalogue
if status {
alreadySeen := make(map[string]bool)
for _, app := range apps {
@ -105,14 +110,9 @@ can take some time.
}
}
statuses, err = config.GetAppStatuses(apps, internal.MachineReadable)
statuses, err = appPkg.GetAppStatuses(apps, internal.MachineReadable)
if err != nil {
logrus.Fatal(err)
}
catl, err = recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -130,7 +130,7 @@ can take some time.
}
}
if app.Recipe == recipeFilter || recipeFilter == "" {
if app.Recipe.Name == recipeFilter || recipeFilter == "" {
if recipeFilter != "" {
// only count server if matches filter
totalServersCount++
@ -177,20 +177,20 @@ can take some time.
var newUpdates []string
if version != "unknown" {
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
updates, err := app.Recipe.Tags()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, update := range updates {
parsedUpdate, err := tagcmp.Parse(update)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
@ -214,7 +214,7 @@ can take some time.
}
appStats.Server = app.Server
appStats.Recipe = app.Recipe
appStats.Recipe = app.Recipe.Name
appStats.AppName = app.Name
appStats.Domain = app.Domain
@ -226,7 +226,7 @@ can take some time.
if internal.MachineReadable {
jsonstring, err := json.Marshal(allStats)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
} else {
fmt.Println(string(jsonstring))
}
@ -241,37 +241,59 @@ can take some time.
serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain"}
headers := []string{"RECIPE", "DOMAIN"}
if status {
tableCol = append(tableCol, []string{"status", "chaos", "version", "upgrade", "autoupdate"}...)
headers = append(headers, []string{
"STATUS",
"CHAOS",
"VERSION",
"UPGRADE",
"AUTOUPDATE"}...,
)
}
table := formatter.CreateTable(tableCol)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
var rows [][]string
for _, appStat := range serverStat.Apps {
tableRow := []string{appStat.Recipe, appStat.Domain}
row := []string{appStat.Recipe, appStat.Domain}
if status {
chaosStatus := appStat.Chaos
if chaosStatus != "unknown" {
chaosEnabled, err := strconv.ParseBool(chaosStatus)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if chaosEnabled && appStat.ChaosVersion != "unknown" {
chaosStatus = appStat.ChaosVersion
}
}
tableRow = append(tableRow, []string{appStat.Status, chaosStatus, appStat.Version, appStat.Upgrade, appStat.AutoUpdate}...)
row = append(row, []string{
appStat.Status,
chaosStatus,
appStat.Version,
appStat.Upgrade,
appStat.AutoUpdate}...,
)
}
table.Append(tableRow)
rows = append(rows, row)
}
if table.NumLines() > 0 {
table.Render()
table.Rows(rows...)
if len(rows) > 0 {
fmt.Println(table)
if status {
fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
"SERVER: %s | TOTAL APPS: %v | VERSIONED: %v | UNVERSIONED: %v | LATEST : %v | UPGRADE: %v",
app.Server,
serverStat.AppCount,
serverStat.VersionCount,
@ -280,19 +302,21 @@ can take some time.
serverStat.UpgradeCount,
))
} else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.AppCount))
log.Infof("SERVER: %s TOTAL APPS: %v", app.Server, serverStat.AppCount)
}
}
if len(allStats) > 1 && table.NumLines() > 0 {
fmt.Println() // newline separator for multiple servers
if len(allStats) > 1 && len(rows) > 0 {
fmt.Println() // newline separator for multiple servers
}
}
alreadySeen[app.Server] = true
}
if len(allStats) > 1 {
fmt.Println(fmt.Sprintf("total servers: %v | total apps: %v ", totalServersCount, totalAppsCount))
totalServers := formatter.BoldStyle.Render("TOTAL SERVERS")
totalApps := formatter.BoldStyle.Render("TOTAL APPS")
log.Infof("%s: %v | %s: %v ", totalServers, totalServersCount, totalApps, totalAppsCount)
}
return nil

View File

@ -9,61 +9,61 @@ import (
"time"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appLogsCommand = cli.Command{
Name: "logs",
Aliases: []string{"l"},
ArgsUsage: "<domain> [<service>]",
Usage: "Tail app logs",
UsageText: "abra app logs <domain> [<service>] [options]",
Flags: []cli.Flag{
internal.StdErrOnlyFlag,
internal.SinceLogsFlag,
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
stackName := app.StackName()
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
serviceName := c.Args().Get(1)
serviceName := cmd.Args().Get(1)
serviceNames := []string{}
if serviceName != "" {
serviceNames = []string{serviceName}
}
err = tailLogs(cl, app, serviceNames)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil
@ -73,7 +73,7 @@ var appLogsCommand = cli.Command{
// tailLogs prints logs for the given app with optional service names to be
// filtered on. It also checks if the latest task is not runnning and then
// prints the past tasks.
func tailLogs(cl *dockerClient.Client, app config.App, serviceNames []string) error {
func tailLogs(cl *dockerClient.Client, app appPkg.App, serviceNames []string) error {
f, err := app.Filters(true, false, serviceNames...)
if err != nil {
return err
@ -101,7 +101,7 @@ func tailLogs(cl *dockerClient.Client, app config.App, serviceNames []string) er
lastTask := tasks[0].Status
if lastTask.State != swarm.TaskStateRunning {
for _, task := range tasks {
logrus.Errorf("[%s] %s State %s: %s", service.Spec.Name, task.Meta.CreatedAt.Format(time.RFC3339), task.Status.State, task.Status.Err)
log.Errorf("[%s] %s State %s: %s", service.Spec.Name, task.Meta.CreatedAt.Format(time.RFC3339), task.Status.State, task.Status.Err)
}
}
}
@ -110,7 +110,7 @@ func tailLogs(cl *dockerClient.Client, app config.App, serviceNames []string) er
// collected in parallel.
wg.Add(1)
go func(serviceID string) {
logs, err := cl.ServiceLogs(context.Background(), serviceID, types.ContainerLogsOptions{
logs, err := cl.ServiceLogs(context.Background(), serviceID, containerTypes.LogsOptions{
ShowStderr: true,
ShowStdout: !internal.StdErrOnly,
Since: internal.SinceLogs,
@ -121,13 +121,13 @@ func tailLogs(cl *dockerClient.Client, app config.App, serviceNames []string) er
Details: false,
})
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
defer logs.Close()
_, err = io.Copy(os.Stdout, logs)
if err != nil && err != io.EOF {
logrus.Fatal(err)
log.Fatal(err)
}
}(service.ID)
}

View File

@ -1,27 +1,29 @@
package app
import (
"context"
"fmt"
"path"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/app"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/jsontable"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/lipgloss/table"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appNewDescription = `
Take a recipe and uses it to create a new app. This new app configuration is
stored in your ~/.abra directory under the appropriate server.
var appNewDescription = `Creates a new app from a default recipe.
This new app configuration is stored in your $ABRA_DIR directory under the
appropriate server.
This command does not deploy your app for you. You will need to run "abra app
deploy <domain>" to do so.
@ -29,6 +31,8 @@ deploy <domain>" to do so.
You can see what recipes are available (i.e. values for the <recipe> argument)
by running "abra recipe ls".
Recipe commit hashes are supported values for "[<version>]".
Passing the "--secrets/-S" flag will automatically generate secrets for your
app and store them encrypted at rest on the chosen target server. These
generated secrets are only visible at generation time, so please take care to
@ -36,115 +40,136 @@ store them somewhere safe.
You can use the "--pass/-P" to store these generated passwords locally in a
pass store (see passwordstore.org for more). The pass command must be available
on your $PATH.
`
on your $PATH.`
var appNewCommand = cli.Command{
Name: "new",
Aliases: []string{"n"},
Usage: "Create a new app",
UsageText: "abra app new [<recipe>] [<version>] [options]",
Description: appNewDescription,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.NewAppServerFlag,
internal.DomainFlag,
internal.PassFlag,
internal.SecretsFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "[<recipe>] [<version>]",
BashComplete: func(ctx *cli.Context) {
args := ctx.Args()
switch len(args) {
Before: internal.SubCommandBefore,
HideHelp: true,
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
args := cmd.Args()
switch args.Len() {
case 0:
autocomplete.RecipeNameComplete(ctx)
autocomplete.RecipeNameComplete(ctx, cmd)
case 1:
autocomplete.RecipeVersionComplete(ctx.Args().Get(0))
autocomplete.RecipeVersionComplete(cmd.Args().Get(0))
}
},
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Action: func(ctx context.Context, cmd *cli.Command) error {
recipe := internal.ValidateRecipe(cmd)
var version string
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(recipe.Name); err != nil {
logrus.Fatal(err)
if err := recipe.EnsureIsClean(); err != nil {
log.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
if err := recipe.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
if c.Args().Get(1) == "" {
if err := recipePkg.EnsureLatest(recipe.Name); err != nil {
logrus.Fatal(err)
if cmd.Args().Get(1) == "" {
recipeVersions, err := recipe.GetRecipeVersions()
if err != nil {
log.Fatal(err)
}
if len(recipeVersions) > 0 {
latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest {
version = tag
}
if _, err := recipe.EnsureVersion(version); err != nil {
log.Fatal(err)
}
} else {
if err := recipe.EnsureLatest(); err != nil {
log.Fatal(err)
}
}
} else {
if err := recipePkg.EnsureVersion(recipe.Name, c.Args().Get(1)); err != nil {
logrus.Fatal(err)
version = cmd.Args().Get(1)
if _, err := recipe.EnsureVersion(version); err != nil {
log.Fatal(err)
}
}
}
if err := ensureServerFlag(); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := ensureDomainFlag(recipe, internal.NewAppServer); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
sanitisedAppName := config.SanitiseAppName(internal.Domain)
logrus.Debugf("%s sanitised as %s for new app", internal.Domain, sanitisedAppName)
sanitisedAppName := appPkg.SanitiseAppName(internal.Domain)
log.Debugf("%s sanitised as %s for new app", internal.Domain, sanitisedAppName)
if err := config.TemplateAppEnvSample(
recipe.Name,
if err := appPkg.TemplateAppEnvSample(
recipe,
internal.Domain,
internal.NewAppServer,
internal.Domain,
); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var secrets AppSecrets
var secretTable *jsontable.JSONTable
var secretsTable *table.Table
if internal.Secrets {
sampleEnv, err := recipe.SampleEnv()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
composeFiles, err := config.GetComposeFiles(recipe.Name, sampleEnv)
composeFiles, err := recipe.GetComposeFiles(sampleEnv)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
envSamplePath := path.Join(config.RECIPES_DIR, recipe.Name, ".env.sample")
secretsConfig, err := secret.ReadSecretsConfig(envSamplePath, composeFiles, config.StackName(internal.Domain))
secretsConfig, err := secret.ReadSecretsConfig(recipe.SampleEnvPath, composeFiles, appPkg.StackName(internal.Domain))
if err != nil {
return err
}
if err := promptForSecrets(recipe.Name, secretsConfig); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
cl, err := client.New(internal.NewAppServer)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secrets, err = createSecrets(cl, secretsConfig, sanitisedAppName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secretCols := []string{"Name", "Value"}
secretTable = formatter.CreateTable(secretCols)
secretsTable, err = formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{"NAME", "VALUE"}
secretsTable.Headers(headers...)
for name, val := range secrets {
secretTable.Append([]string{name, val})
secretsTable.Row(name, val)
}
}
@ -152,26 +177,50 @@ var appNewCommand = cli.Command{
internal.NewAppServer = "local"
}
tableCol := []string{"server", "recipe", "domain"}
table := formatter.CreateTable(tableCol)
table.Append([]string{internal.NewAppServer, recipe.Name, internal.Domain})
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{"SERVER", "DOMAIN", "RECIPE", "VERSION"}
table.Headers(headers...)
table.Row(internal.NewAppServer, internal.Domain, recipe.Name, version)
log.Infof("new app '%s' created 🌞", recipe.Name)
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
fmt.Println("")
table.Render()
fmt.Println(table)
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println("Configure this app:")
fmt.Println(fmt.Sprintf("\n abra app config %s", internal.Domain))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println("Deploy this app:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", internal.Domain))
if len(secrets) > 0 {
fmt.Println("")
fmt.Println("Here are your generated secrets:")
fmt.Println("Generated secrets:")
fmt.Println("")
secretTable.Render()
logrus.Warn("generated secrets are not shown again, please take note of them NOW")
fmt.Println(secretsTable)
log.Warnf(
"generated secrets %s shown again, please take note of them %s",
formatter.BoldStyle.Render("NOT"),
formatter.BoldStyle.Render("NOW"),
)
}
app, err := app.Get(internal.Domain)
if err != nil {
log.Fatal(err)
}
log.Debugf("choosing %s as version to save to env file", version)
if err := app.WriteRecipeVersion(version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err)
}
return nil
@ -183,6 +232,12 @@ type AppSecrets map[string]string
// createSecrets creates all secrets for a new app.
func createSecrets(cl *dockerClient.Client, secretsConfig map[string]secret.Secret, sanitisedAppName string) (AppSecrets, error) {
// NOTE(d1): trim to match app.StackName() implementation
if len(sanitisedAppName) > config.MAX_SANITISED_APP_NAME_LENGTH {
log.Debugf("trimming %s to %s to avoid runtime limits", sanitisedAppName, sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH])
sanitisedAppName = sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH]
}
secrets, err := secret.GenerateSecrets(cl, secretsConfig, internal.NewAppServer)
if err != nil {
return nil, err
@ -206,7 +261,7 @@ func createSecrets(cl *dockerClient.Client, secretsConfig map[string]secret.Secr
}
// ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/
func ensureDomainFlag(recipe recipe.Recipe, server string) error {
func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
if internal.Domain == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "Specify app domain",
@ -227,7 +282,7 @@ func ensureDomainFlag(recipe recipe.Recipe, server string) error {
// promptForSecrets asks if we should generate secrets for a new app.
func promptForSecrets(recipeName string, secretsConfig map[string]secret.Secret) error {
if len(secretsConfig) == 0 {
logrus.Debugf("%s has no secrets to generate, skipping...", recipeName)
log.Debugf("%s has no secrets to generate, skipping...", recipeName)
return nil
}

View File

@ -2,100 +2,176 @@ package app
import (
"context"
"strings"
"time"
"encoding/json"
"fmt"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/service"
"coopcloud.tech/abra/pkg/log"
abraService "coopcloud.tech/abra/pkg/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/buger/goterm"
dockerFormatter "github.com/docker/cli/cli/command/formatter"
"github.com/docker/docker/api/types"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appPsCommand = cli.Command{
Name: "ps",
Aliases: []string{"p"},
Usage: "Check app status",
ArgsUsage: "<domain>",
Description: "Show a more detailed status output of a specific deployed app",
UsageText: "abra app ps <domain> [options]",
Description: "Show status of a deployed app.",
Flags: []cli.Flag{
internal.WatchFlag,
internal.DebugFlag,
internal.MachineReadableFlag,
internal.ChaosFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
if !internal.Watch {
showPSOutput(c, app, cl)
return nil
chaosVersion := config.CHAOS_DEFAULT
statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true)
if statusMeta, ok := statuses[app.StackName()]; ok {
if isChaos, exists := statusMeta["chaos"]; exists && isChaos == "true" {
chaosVersion, err = app.Recipe.ChaosVersion()
if err != nil {
log.Fatal(err)
}
}
}
goterm.Clear()
for {
goterm.MoveCursor(1, 1)
showPSOutput(c, app, cl)
goterm.Flush()
time.Sleep(2 * time.Second)
}
showPSOutput(app, cl, deployMeta.Version, chaosVersion)
return nil
},
}
// showPSOutput renders ps output.
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
filters, err := app.Filters(true, true)
func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chaosVersion string) {
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
return
}
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: app.StackName(),
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
return
}
tableCol := []string{"service name", "image", "created", "status", "state", "ports"}
table := formatter.CreateTable(tableCol)
var rows [][]string
allContainerStats := make(map[string]map[string]string)
for _, service := range compose.Services {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
for _, container := range containers {
var containerNames []string
for _, containerName := range container.Names {
trimmed := strings.TrimPrefix(containerName, "/")
containerNames = append(containerNames, trimmed)
containers, err := cl.ContainerList(context.Background(), containerTypes.ListOptions{Filters: filters})
if err != nil {
log.Fatal(err)
return
}
tableRow := []string{
service.ContainerToServiceName(container.Names, app.StackName()),
formatter.RemoveSha(container.Image),
formatter.HumanDuration(container.Created),
container.Status,
container.State,
dockerFormatter.DisplayablePorts(container.Ports),
var containerStats map[string]string
if len(containers) == 0 {
containerStats = map[string]string{
"version": deployedVersion,
"chaos": chaosVersion,
"service": service.Name,
"image": "unknown",
"created": "unknown",
"status": "unknown",
"state": "unknown",
"ports": "unknown",
}
} else {
container := containers[0]
containerStats = map[string]string{
"version": deployedVersion,
"chaos": chaosVersion,
"service": abraService.ContainerToServiceName(container.Names, app.StackName()),
"image": formatter.RemoveSha(container.Image),
"created": formatter.HumanDuration(container.Created),
"status": container.Status,
"state": container.State,
"ports": dockerFormatter.DisplayablePorts(container.Ports),
}
}
table.Append(tableRow)
allContainerStats[containerStats["service"]] = containerStats
row := []string{
containerStats["service"],
containerStats["image"],
containerStats["created"],
containerStats["status"],
containerStats["state"],
containerStats["ports"],
}
rows = append(rows, row)
}
table.Render()
if internal.MachineReadable {
jsonstring, err := json.Marshal(allContainerStats)
if err != nil {
log.Fatal("unable to convert to JSON: %s", err)
}
fmt.Println(string(jsonstring))
return
}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{
"SERVICE",
"IMAGE",
"CREATED",
"STATUS",
"STATE",
"PORTS",
}
table.
Headers(headers...).
Rows(rows...)
fmt.Println(table)
log.Infof("VERSION: %s CHAOS: %s", deployedVersion, chaosVersion)
}

View File

@ -3,28 +3,24 @@ package app
import (
"context"
"fmt"
"log"
"os"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/log"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/volume"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appRemoveCommand = cli.Command{
Name: "remove",
Aliases: []string{"rm"},
ArgsUsage: "<domain>",
UsageText: "abra app remove <domain> [options]",
Usage: "Remove all app data, locally and remotely",
Description: `
This command removes everything related to an app which is already undeployed.
Description: `Remove everything related to an app which is already undeployed.
By default, it will prompt for confirmation before proceeding. All secrets,
volumes and the local app env file will be deleted.
@ -39,52 +35,51 @@ Please note, if you delete the local app env file without removing volumes and
secrets first, Abra will *not* be able to help you remove them afterwards.
To delete everything without prompt, use the "--force/-f" or the "--no-input/n"
flag.
`,
flag.`,
Flags: []cli.Flag{
internal.ForceFlag,
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
},
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
ShellComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if !internal.Force && !internal.NoInput {
log.Warnf("ALERTA ALERTA: this will completely remove %s data and config locally and remotely", app.Name)
response := false
msg := "ALERTA ALERTA: this will completely remove %s data and configurations locally and remotely, are you sure?"
prompt := &survey.Confirm{Message: fmt.Sprintf(msg, app.Name)}
prompt := &survey.Confirm{Message: "are you sure?"}
if err := survey.AskOne(prompt, &response); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !response {
logrus.Fatal("aborting as requested")
log.Fatal("aborting as requested")
}
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
if deployMeta.IsDeployed {
log.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
}
fs, err := app.Filters(false, false)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: fs})
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secrets := make(map[string]string)
@ -99,69 +94,42 @@ flag.
for _, name := range secretNames {
err := cl.SecretRemove(context.Background(), secrets[name])
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Info(fmt.Sprintf("secret: %s removed", name))
log.Info(fmt.Sprintf("secret: %s removed", name))
}
} else {
logrus.Info("no secrets to remove")
log.Info("no secrets to remove")
}
fs, err = app.Filters(false, true)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volumeListOptions := volume.ListOptions{fs}
volumeListOKBody, err := cl.VolumeList(context.Background(), volumeListOptions)
volumeList := volumeListOKBody.Volumes
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, fs)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volumeNames := client.GetVolumeNames(volumeList)
var vols []string
for _, vol := range volumeList {
vols = append(vols, vol.Name)
}
if len(vols) > 0 {
for _, vol := range vols {
err = retryFunc(5, func() error {
return cl.VolumeRemove(context.Background(), vol, internal.Force) // last argument is for force removing
})
if err != nil {
log.Fatalf("removing volumes failed: %s", err)
}
logrus.Info(fmt.Sprintf("volume %s removed", vol))
if len(volumeNames) > 0 {
err := client.RemoveVolumes(cl, context.Background(), volumeNames, internal.Force, 5)
if err != nil {
log.Fatalf("removing volumes failed: %s", err)
}
log.Infof("%d volumes removed successfully", len(volumeNames))
} else {
logrus.Info("no volumes to remove")
log.Info("no volumes to remove")
}
if err = os.Remove(app.Path); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Info(fmt.Sprintf("file: %s removed", app.Path))
log.Info(fmt.Sprintf("file: %s removed", app.Path))
return nil
},
}
// retryFunc retries the given function for the given retries. After the nth
// retry it waits (n + 1)^2 seconds before the next retry (starting with n=0).
// It returns an error if the function still failed after the last retry.
func retryFunc(retries int, fn func() error) error {
for i := 0; i < retries; i++ {
err := fn()
if err == nil {
return nil
}
if i+1 < retries {
sleep := time.Duration(i+1) * time.Duration(i+1)
logrus.Infof("%s: waiting %d seconds before next retry", err, sleep)
time.Sleep(sleep * time.Second)
}
}
return fmt.Errorf("%d retries failed", retries)
}

View File

@ -6,75 +6,100 @@ import (
"fmt"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/log"
upstream "coopcloud.tech/abra/pkg/upstream/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appRestartCommand = cli.Command{
Name: "restart",
Aliases: []string{"re"},
Usage: "Restart an app",
ArgsUsage: "<domain>",
UsageText: "abra app restart <domain> [<service>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.AllServicesFlag,
},
Before: internal.SubCommandBefore,
Description: `This command restarts a service within a deployed app.`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
Description: `This command restarts services within a deployed app.
serviceNameShort := c.Args().Get(1)
if serviceNameShort == "" {
err := errors.New("missing service?")
internal.ShowSubcommandHelpAndError(c, err)
Run "abra app ps <domain>" to see a list of service names.
Pass "--all-services/-a" to restart all services.`,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(false, false); err != nil {
log.Fatal(err)
}
serviceName := cmd.Args().Get(1)
if serviceName == "" && !internal.AllServices {
err := errors.New("missing <service>")
internal.ShowSubcommandHelpAndError(cmd, err)
}
if serviceName != "" && internal.AllServices {
log.Fatal("cannot use <service> and --all-services together")
}
var serviceNames []string
if internal.AllServices {
var err error
serviceNames, err = appPkg.GetAppServiceNames(app.Name)
if err != nil {
log.Fatal(err)
}
} else {
serviceNames = append(serviceNames, serviceName)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort)
for _, serviceName := range serviceNames {
stackServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName)
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 0); err != nil {
logrus.Fatal(err)
log.Debugf("attempting to scale %s to 0", stackServiceName)
if err := upstream.RunServiceScale(context.Background(), cl, stackServiceName, 0); err != nil {
log.Fatal(err)
}
if err := stack.WaitOnService(context.Background(), cl, stackServiceName, app.Name); err != nil {
log.Fatal(err)
}
log.Debugf("%s has been scaled to 0", stackServiceName)
log.Debugf("attempting to scale %s to 1", stackServiceName)
if err := upstream.RunServiceScale(context.Background(), cl, stackServiceName, 1); err != nil {
log.Fatal(err)
}
if err := stack.WaitOnService(context.Background(), cl, stackServiceName, app.Name); err != nil {
log.Fatal(err)
}
log.Debugf("%s has been scaled to 1", stackServiceName)
log.Infof("%s service successfully restarted", serviceName)
}
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName)
logrus.Debugf("attempting to scale %s to 1 (restart logic)", serviceName)
if err := upstream.RunServiceScale(context.Background(), cl, serviceName, 1); err != nil {
logrus.Fatal(err)
}
if err := stack.WaitOnService(context.Background(), cl, serviceName, app.Name); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("%s has been scaled to 1 (restart logic)", serviceName)
logrus.Infof("%s service successfully restarted", serviceNameShort)
return nil
},
}

View File

@ -2,222 +2,64 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"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"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
type restoreConfig struct {
preHookCmd string
postHookCmd string
var targetPath string
var targetPathFlag = &cli.StringFlag{
Name: "target",
Aliases: []string{"t"},
Usage: "Target path",
Destination: &targetPath,
}
var appRestoreCommand = cli.Command{
Name: "restore",
Aliases: []string{"rs"},
Usage: "Run app restore",
ArgsUsage: "<domain> <service> <file>",
Usage: "Restore an app backup",
UsageText: "abra app restore <domain> <service> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
targetPathFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Description: `
Run an app restore.
Pre/post hook commands are defined in the recipe configuration. Abra reads this
configuration and run the comands in the context of the service before
restoring the backup.
Unlike "abra app backup", restore must be run on a per-service basis. You can
not restore all services in one go. Backup files produced by Abra are
compressed archives which use absolute paths. This allows Abra to restore
according to standard tar command logic, i.e. the backup will be restored to
the path it was originally backed up from.
Example:
abra app restore example.com app ~/.abra/backups/example_com_app_609341138.tar.gz
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
recipe, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
serviceName := c.Args().Get(1)
if serviceName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <service>?"))
}
backupPath := c.Args().Get(2)
if backupPath == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("missing <file>?"))
}
if _, err := os.Stat(backupPath); err != nil {
if os.IsNotExist(err) {
logrus.Fatalf("%s doesn't exist?", backupPath)
}
}
restoreConfigs := make(map[string]restoreConfig)
for _, service := range recipe.Config.Services {
if restoreEnabled, ok := service.Deploy.Labels["backupbot.restore"]; ok {
if restoreEnabled == "true" {
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name)
rsConfig := restoreConfig{}
logrus.Debugf("restore config detected for %s", fullServiceName)
if preHookCmd, ok := service.Deploy.Labels["backupbot.restore.pre-hook"]; ok {
logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd)
rsConfig.preHookCmd = preHookCmd
}
if postHookCmd, ok := service.Deploy.Labels["backupbot.restore.post-hook"]; ok {
logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd)
rsConfig.postHookCmd = postHookCmd
}
restoreConfigs[service.Name] = rsConfig
}
}
}
rsConfig, ok := restoreConfigs[serviceName]
if !ok {
rsConfig = restoreConfig{}
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := runRestore(cl, app, backupPath, serviceName, rsConfig); err != nil {
logrus.Fatal(err)
targetContainer, err := internal.RetrieveBackupBotContainer(cl)
if err != nil {
log.Fatal(err)
}
execEnv := []string{fmt.Sprintf("SERVICE=%s", app.Domain)}
if snapshot != "" {
log.Debugf("including SNAPSHOT=%s in backupbot exec invocation", snapshot)
execEnv = append(execEnv, fmt.Sprintf("SNAPSHOT=%s", snapshot))
}
if targetPath != "" {
log.Debugf("including TARGET=%s in backupbot exec invocation", targetPath)
execEnv = append(execEnv, fmt.Sprintf("TARGET=%s", targetPath))
}
if err := internal.RunBackupCmdRemote(cl, "restore", targetContainer.ID, execEnv); err != nil {
log.Fatal(err)
}
return nil
},
}
// runRestore does the actual restore logic.
func runRestore(cl *dockerClient.Client, app config.App, backupPath, serviceName string, rsConfig restoreConfig) error {
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
return err
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
if err != nil {
return err
}
fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
if rsConfig.preHookCmd != "" {
splitCmd := internal.SafeSplit(rsConfig.preHookCmd)
logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd)
preHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, rsConfig.preHookCmd)
}
backupReader, err := os.Open(backupPath)
if err != nil {
return err
}
content, err := archive.DecompressStream(backupReader)
if err != nil {
return err
}
// NOTE(d1): we use absolute paths so tar knows what to do. it will restore
// files according to the paths set in the compressed archive
restorePath := "/"
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(context.Background(), targetContainer.ID, restorePath, content, copyOpts); err != nil {
return err
}
logrus.Infof("restored %s to %s", backupPath, fullServiceName)
if rsConfig.postHookCmd != "" {
splitCmd := internal.SafeSplit(rsConfig.postHookCmd)
logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd)
postHookExecOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: splitCmd,
Detach: false,
Tty: true,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil {
return err
}
logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, rsConfig.postHookCmd)
}
return nil
}

View File

@ -4,235 +4,221 @@ import (
"context"
"fmt"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/log"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appRollbackCommand = cli.Command{
Name: "rollback",
Aliases: []string{"rl"},
Usage: "Roll an app back to a previous version",
ArgsUsage: "<domain> [<version>]",
UsageText: "abra app rollback <domain> [<version>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag,
internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
This command rolls an app back to a previous version if one exists.
Description: `This command rolls an app back to a previous version.
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.
Unlike "deploy", chaos operations are not supported here. Only recipe versions
are supported values for "[<version>]".
This action could be destructive, please ensure you have a copy of your app
data beforehand.
A rollback can be destructive, please ensure you have a copy of your app data
beforehand.`,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
var warnMessages []string
Chaos mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
app := internal.ValidateApp(cmd)
stackName := app.StackName()
specificVersion := c.Args().Get(1)
if specificVersion != "" && internal.Chaos {
logrus.Fatal("cannot use <version> and --chaos together")
specificVersion := cmd.Args().Get(1)
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
r, err := recipe.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
if err := lint.LintForErrors(app.Recipe); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
log.Debugf("checking whether %s is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
versions, err := app.Recipe.Tags()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil {
logrus.Warn(err)
}
for _, recipeVersion := range recipeVersions {
for version := range recipeVersion {
versions = append(versions, version)
}
}
log.Fatal(err)
}
var availableDowngrades []string
if deployedVersion == "unknown" {
if deployMeta.Version == "unknown" {
availableDowngrades = versions
logrus.Warnf("failed to determine deployed version of %s", app.Name)
warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
}
if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
logrus.Fatal(err)
log.Fatalf("'%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
}
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
if err != nil {
logrus.Fatal(err)
log.Fatalf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
}
if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) || parsedSpecificVersion.Equals(parsedDeployedVersion) {
logrus.Fatalf("%s is not a downgrade for %s?", deployedVersion, specificVersion)
if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) && !parsedSpecificVersion.Equals(parsedDeployedVersion) {
log.Fatalf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
}
if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
log.Fatalf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
}
availableDowngrades = append(availableDowngrades, specificVersion)
}
if deployedVersion != "unknown" && !internal.Chaos && specificVersion == "" {
if deployMeta.Version != "unknown" && specificVersion == "" {
if deployMeta.IsChaos {
warnMessages = append(warnMessages, fmt.Sprintf("attempting to rollback a chaos deployment"))
}
for _, version := range versions {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if parsedVersion.IsLessThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) {
availableDowngrades = append(availableDowngrades, version)
}
}
if len(availableDowngrades) == 0 && !internal.Force {
logrus.Info("no available downgrades, you're on oldest ✌️")
log.Info("no available downgrades")
return nil
}
}
var chosenDowngrade string
if len(availableDowngrades) > 0 && !internal.Chaos {
if len(availableDowngrades) > 0 {
if internal.Force || internal.NoInput || specificVersion != "" {
chosenDowngrade = availableDowngrades[len(availableDowngrades)-1]
logrus.Debugf("choosing %s as version to downgrade to (--force/--no-input)", chosenDowngrade)
log.Debugf("choosing %s as version to downgrade to (--force/--no-input)", chosenDowngrade)
} else {
msg := fmt.Sprintf("please select a downgrade (version: %s):", deployMeta.Version)
if deployMeta.IsChaos {
msg = fmt.Sprintf("please select a downgrade (version: %s, chaosVersion: %s):", deployMeta.Version, deployMeta.ChaosVersion)
}
prompt := &survey.Select{
Message: fmt.Sprintf("Please select a downgrade (current version: %s):", deployedVersion),
Message: msg,
Options: internal.ReverseStringList(availableDowngrades),
}
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
return err
}
}
}
if !internal.Chaos {
if err := recipe.EnsureVersion(app.Recipe, chosenDowngrade); err != nil {
logrus.Fatal(err)
}
log.Debugf("choosing %s as version to rollback", chosenDowngrade)
if _, err := app.Recipe.EnsureVersion(chosenDowngrade); err != nil {
log.Fatal(err)
}
if internal.Chaos {
logrus.Warn("chaos mode engaged")
var err error
chosenDowngrade, err = recipe.ChaosVersion(app.Recipe)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
appPkg.ExposeAllEnv(stackName, compose, app.Env)
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
appPkg.SetUpdateLabel(compose, stackName, app.Env)
chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion
}
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
config.SetUpdateLabel(compose, stackName, app.Env)
// NOTE(d1): no release notes implemeneted for rolling back
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil {
logrus.Fatal(err)
if err := internal.NewVersionOverview(
app,
warnMessages,
"rollback",
deployMeta.Version,
chaosVersion,
chosenDowngrade,
""); err != nil {
log.Fatal(err)
}
if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
app.Recipe.Version = chosenDowngrade
log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err)
}
return nil

View File

@ -9,24 +9,26 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var user string
var userFlag = &cli.StringFlag{
Name: "user, u",
Name: "user",
Aliases: []string{"u"},
Value: "",
Destination: &user,
}
var noTTY bool
var noTTYFlag = &cli.BoolFlag{
Name: "no-tty, t",
Name: "no-tty",
Aliases: []string{"t"},
Destination: &noTTY,
}
@ -34,46 +36,46 @@ var appRunCommand = cli.Command{
Name: "run",
Aliases: []string{"r"},
Flags: []cli.Flag{
internal.DebugFlag,
noTTYFlag,
userFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> <service> <args>...",
Usage: "Run a command in a service container",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
Usage: "Run a command in an app service",
UsageText: "abra app run <domain> <service> <args> [options]",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if len(c.Args()) < 2 {
internal.ShowSubcommandHelpAndError(c, errors.New("no <service> provided?"))
if cmd.Args().Len() < 2 {
internal.ShowSubcommandHelpAndError(cmd, errors.New("no <service> provided?"))
}
if len(c.Args()) < 3 {
internal.ShowSubcommandHelpAndError(c, errors.New("no <args> provided?"))
if cmd.Args().Len() < 3 {
internal.ShowSubcommandHelpAndError(cmd, errors.New("no <args> provided?"))
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
serviceName := c.Args().Get(1)
serviceName := cmd.Args().Get(1)
stackAndServiceName := fmt.Sprintf("^%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs()
filters.Add("name", stackAndServiceName)
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
cmd := c.Args()[2:]
c := cmd.Args().Slice()[2:]
execCreateOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: cmd,
Cmd: c,
Detach: false,
Tty: true,
}
@ -88,11 +90,11 @@ var appRunCommand = cli.Command{
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
logrus.Fatal(err)
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
log.Fatal(err)
}
return nil

View File

@ -6,24 +6,25 @@ import (
"fmt"
"os"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/secret"
"github.com/docker/docker/api/types"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var (
allSecrets bool
allSecretsFlag = &cli.BoolFlag{
Name: "all, a",
Name: "all",
Aliases: []string{"a"},
Destination: &allSecrets,
Usage: "Generate all secrets",
}
@ -32,7 +33,8 @@ var (
var (
rmAllSecrets bool
rmAllSecretsFlag = &cli.BoolFlag{
Name: "all, a",
Name: "all",
Aliases: []string{"a"},
Destination: &rmAllSecrets,
Usage: "Remove all secrets",
}
@ -42,66 +44,48 @@ var appSecretGenerateCommand = cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate secrets",
ArgsUsage: "<domain> <secret> <version>",
UsageText: "abra app secret generate <domain> <secret> <version> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
allSecretsFlag,
internal.PassFlag,
internal.MachineReadableFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if len(c.Args()) == 1 && !allSecrets {
if cmd.Args().Len() == 1 && !allSecrets {
err := errors.New("missing arguments <secret>/<version> or '--all'")
internal.ShowSubcommandHelpAndError(c, err)
internal.ShowSubcommandHelpAndError(cmd, err)
}
if c.Args().Get(1) != "" && allSecrets {
if cmd.Args().Get(1) != "" && allSecrets {
err := errors.New("cannot use '<secret> <version>' and '--all' together")
internal.ShowSubcommandHelpAndError(c, err)
internal.ShowSubcommandHelpAndError(cmd, err)
}
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !allSecrets {
secretName := c.Args().Get(1)
secretVersion := c.Args().Get(2)
secretName := cmd.Args().Get(1)
secretVersion := cmd.Args().Get(2)
s, ok := secrets[secretName]
if !ok {
logrus.Fatalf("%s doesn't exist in the env config?", secretName)
log.Fatalf("%s doesn't exist in the env config?", secretName)
}
s.Version = secretVersion
secrets = map[string]secret.Secret{
@ -111,93 +95,123 @@ var appSecretGenerateCommand = cli.Command{
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secretVals, err := secret.GenerateSecrets(cl, secrets, app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if internal.Pass {
for name, data := range secretVals {
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
}
if len(secretVals) == 0 {
logrus.Warn("no secrets generated")
log.Warn("no secrets generated")
os.Exit(1)
}
tableCol := []string{"name", "value"}
table := formatter.CreateTable(tableCol)
headers := []string{"NAME", "VALUE"}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
var rows [][]string
for name, val := range secretVals {
table.Append([]string{name, val})
row := []string{name, val}
rows = append(rows, row)
table.Row(row...)
}
if internal.MachineReadable {
table.JSONRender()
} else {
table.Render()
out, err := formatter.ToJSON(headers, rows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
}
logrus.Warn("generated secrets are not shown again, please take note of them NOW")
fmt.Println(table)
log.Warnf(
"generated secrets %s shown again, please take note of them %s",
formatter.BoldStyle.Render("NOT"),
formatter.BoldStyle.Render("NOW"),
)
return nil
},
}
var appSecretInsertCommand = cli.Command{
Name: "insert",
Aliases: []string{"i"},
Usage: "Insert secret",
Name: "insert",
Aliases: []string{"i"},
Usage: "Insert secret",
UsageText: "abra app secret insert <domain> <secret> <version> <data> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.PassFlag,
internal.FileFlag,
internal.TrimFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> <secret-name> <version> <data>",
BashComplete: autocomplete.AppNameComplete,
Description: `
This command inserts a secret into an app environment.
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelpCommand: true,
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).
(see "abra app new --secrets" for more).`,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
Example:
abra app secret insert myapp db_pass v1 mySecretPassword
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if len(c.Args()) != 4 {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
if cmd.Args().Len() != 4 {
internal.ShowSubcommandHelpAndError(cmd, errors.New("missing arguments?"))
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
name := c.Args().Get(1)
version := c.Args().Get(2)
data := c.Args().Get(3)
name := cmd.Args().Get(1)
version := cmd.Args().Get(2)
data := cmd.Args().Get(3)
if internal.File {
raw, err := os.ReadFile(data)
if err != nil {
log.Fatalf("reading secret from file: %s", err)
}
data = string(raw)
}
if internal.Trim {
data = strings.TrimSpace(data)
}
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
if err := client.StoreSecret(cl, secretName, data, app.Server); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Infof("%s successfully stored on server", secretName)
log.Infof("%s successfully stored on server", secretName)
if internal.Pass {
if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -206,100 +220,82 @@ Example:
}
// secretRm removes a secret.
func secretRm(cl *dockerClient.Client, app config.App, secretName, parsed string) error {
func secretRm(cl *dockerClient.Client, app appPkg.App, secretName, parsed string) error {
if err := cl.SecretRemove(context.Background(), secretName); err != nil {
return err
}
logrus.Infof("deleted %s successfully from server", secretName)
log.Infof("deleted %s successfully from server", secretName)
if internal.PassRemove {
if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil {
return err
}
logrus.Infof("deleted %s successfully from local pass store", secretName)
log.Infof("deleted %s successfully from local pass store", secretName)
}
return nil
}
var appSecretRmCommand = cli.Command{
Name: "remove",
Aliases: []string{"rm"},
Usage: "Remove a secret",
Name: "remove",
Aliases: []string{"rm"},
Usage: "Remove a secret",
UsageText: "abra app remove <domainabra app remove <domain> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
rmAllSecretsFlag,
internal.PassRemoveFlag,
internal.OfflineFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain> [<secret-name>]",
BashComplete: autocomplete.AppNameComplete,
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
Description: `
This command removes app secrets.
Example:
abra app secret remove myapp db_pass
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
abra app secret remove myapp db_pass`,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if c.Args().Get(1) != "" && rmAllSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<secret-name>' and '--all' together"))
if cmd.Args().Get(1) != "" && rmAllSecrets {
internal.ShowSubcommandHelpAndError(cmd, errors.New("cannot use '<secret-name>' and '--all' together"))
}
if c.Args().Get(1) == "" && !rmAllSecrets {
internal.ShowSubcommandHelpAndError(c, errors.New("no secret(s) specified?"))
if cmd.Args().Get(1) == "" && !rmAllSecrets {
internal.ShowSubcommandHelpAndError(cmd, errors.New("no secret(s) specified?"))
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
filters, err := app.Filters(false, false)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
remoteSecretNames := make(map[string]bool)
@ -308,14 +304,14 @@ Example:
}
match := false
secretToRm := c.Args().Get(1)
secretToRm := cmd.Args().Get(1)
for secretName, val := range secrets {
secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version)
if _, ok := remoteSecretNames[secretRemoteName]; ok {
if secretToRm != "" {
if secretName == secretToRm {
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil
@ -324,18 +320,18 @@ Example:
match = true
if err := secretRm(cl, app, secretRemoteName, secretName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
}
}
if !match && secretToRm != "" {
logrus.Fatalf("%s doesn't exist on server?", secretToRm)
log.Fatalf("%s doesn't exist on server?", secretToRm)
}
if !match {
logrus.Fatal("no secrets to remove?")
log.Fatal("no secrets to remove?")
}
return nil
@ -346,70 +342,68 @@ var appSecretLsCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.ChaosFlag,
internal.MachineReadableFlag,
},
Before: internal.SubCommandBefore,
Usage: "List all secrets",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
if err := recipe.EnsureExists(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
Before: internal.SubCommandBefore,
Usage: "List all secrets",
UsageText: "abra app secret list [options]",
HideHelp: true,
ShellComplete: autocomplete.AppNameComplete,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"}
table := formatter.CreateTable(tableCol)
headers := []string{"NAME", "VERSION", "GENERATED NAME", "CREATED ON SERVER"}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
secStats, err := secret.PollSecretsStatus(cl, app)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var rows [][]string
for _, secStat := range secStats {
tableRow := []string{
row := []string{
secStat.LocalName,
secStat.Version,
secStat.RemoteName,
strconv.FormatBool(secStat.CreatedOnRemote),
}
table.Append(tableRow)
rows = append(rows, row)
table.Row(row...)
}
if table.NumLines() > 0 {
if len(rows) > 0 {
if internal.MachineReadable {
table.JSONRender()
} else {
table.Render()
out, err := formatter.ToJSON(headers, rows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
}
} else {
logrus.Warnf("no secrets stored for %s", app.Name)
fmt.Println(table)
return nil
}
log.Warnf("no secrets stored for %s", app.Name)
return nil
},
}
@ -418,11 +412,11 @@ var appSecretCommand = cli.Command{
Name: "secret",
Aliases: []string{"s"},
Usage: "Manage app secrets",
ArgsUsage: "<domain>",
Subcommands: []cli.Command{
appSecretGenerateCommand,
appSecretInsertCommand,
appSecretRmCommand,
appSecretLsCommand,
UsageText: "abra app secret [command] [arguments] [options]",
Commands: []*cli.Command{
&appSecretGenerateCommand,
&appSecretInsertCommand,
&appSecretRmCommand,
&appSecretLsCommand,
},
}

View File

@ -9,53 +9,60 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/urfave/cli/v3"
)
var appServicesCommand = cli.Command{
Name: "services",
Aliases: []string{"sr"},
Usage: "Display all services of an app",
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Name: "services",
Aliases: []string{"sr"},
Usage: "Display all services of an app",
UsageText: "abra app services <domain> [options]",
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
filters, err := app.Filters(true, true)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
containers, err := cl.ContainerList(context.Background(), types.ContainerListOptions{Filters: filters})
containers, err := cl.ContainerList(context.Background(), containerTypes.ListOptions{Filters: filters})
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
tableCol := []string{"service name", "image"}
table := formatter.CreateTable(tableCol)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{"SERVICE (SHORT)", "SERVICE (LONG)", "IMAGE"}
table.Headers(headers...)
var rows [][]string
for _, container := range containers {
var containerNames []string
for _, containerName := range container.Names {
@ -66,14 +73,20 @@ var appServicesCommand = cli.Command{
serviceShortName := service.ContainerToServiceName(container.Names, app.StackName())
serviceLongName := fmt.Sprintf("%s_%s", app.StackName(), serviceShortName)
tableRow := []string{
row := []string{
serviceShortName,
serviceLongName,
formatter.RemoveSha(container.Image),
}
table.Append(tableRow)
rows = append(rows, row)
}
table.Render()
table.Rows(rows...)
if len(rows) > 0 {
fmt.Println(table)
}
return nil
},

View File

@ -3,24 +3,25 @@ package app
import (
"context"
"fmt"
"time"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var prune bool
var pruneFlag = &cli.BoolFlag{
Name: "prune, p",
Name: "prune",
Aliases: []string{"p"},
Destination: &prune,
Usage: "Prunes unused containers, networks, and dangling images for an app",
}
@ -28,27 +29,10 @@ var pruneFlag = &cli.BoolFlag{
// pruneApp runs the equivalent of a "docker system prune" but only filtering
// against resources connected with the app deployment. It is not a system wide
// prune. Volumes are not pruned to avoid unwated data loss.
func pruneApp(c *cli.Context, cl *dockerClient.Client, app config.App) error {
func pruneApp(cl *dockerClient.Client, app appPkg.App) error {
stackName := app.StackName()
ctx := context.Background()
for {
logrus.Debugf("polling for %s stack, waiting to be undeployed...", stackName)
services, err := stack.GetStackServices(ctx, cl, stackName)
if err != nil {
return err
}
if len(services) == 0 {
logrus.Debugf("%s undeployed, moving on with pruning logic", stackName)
time.Sleep(time.Second) // give runtime more time to tear down related state
break
}
time.Sleep(time.Second)
}
pruneFilters := filters.NewArgs()
stackSearch := fmt.Sprintf("%s*", stackName)
pruneFilters.Add("label", stackSearch)
@ -58,14 +42,14 @@ func pruneApp(c *cli.Context, cl *dockerClient.Client, app config.App) error {
}
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
logrus.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
log.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
nr, err := cl.NetworksPrune(ctx, pruneFilters)
if err != nil {
return err
}
logrus.Infof("networks pruned: %d", len(nr.NetworksDeleted))
log.Infof("networks pruned: %d", len(nr.NetworksDeleted))
ir, err := cl.ImagesPrune(ctx, pruneFilters)
if err != nil {
@ -73,7 +57,7 @@ func pruneApp(c *cli.Context, cl *dockerClient.Client, app config.App) error {
}
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
logrus.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
log.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
return nil
}
@ -81,55 +65,62 @@ func pruneApp(c *cli.Context, cl *dockerClient.Client, app config.App) error {
var appUndeployCommand = cli.Command{
Name: "undeploy",
Aliases: []string{"un"},
ArgsUsage: "<domain>",
UsageText: "abra app undeploy <domain> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
pruneFlag,
},
Before: internal.SubCommandBefore,
Usage: "Undeploy an app",
BashComplete: autocomplete.AppNameComplete,
Description: `
This does not destroy any of the application data.
Before: internal.SubCommandBefore,
Usage: "Undeploy an app",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Description: `This does not destroy any of the application data.
However, you should remain vigilant, as your swarm installation will consider
any previously attached volumes as eligible for pruning once undeployed.
Passing "-p/--prune" does not remove those volumes.
`,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Passing "-p/--prune" does not remove those volumes.`,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
stackName := app.StackName()
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
log.Debugf("checking whether %s is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {
logrus.Fatal(err)
chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion
}
rmOpts := stack.Remove{Namespaces: []string{app.StackName()}}
if err := internal.DeployOverview(app, []string{}, deployMeta.Version, chaosVersion); err != nil {
log.Fatal(err)
}
rmOpts := stack.Remove{
Namespaces: []string{app.StackName()},
Detach: false,
}
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if prune {
if err := pruneApp(c, cl, app); err != nil {
logrus.Fatal(err)
if err := pruneApp(cl, app); err != nil {
log.Fatal(err)
}
}

View File

@ -5,157 +5,122 @@ import (
"fmt"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/log"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appUpgradeCommand = cli.Command{
Name: "upgrade",
Aliases: []string{"up"},
Usage: "Upgrade an app",
ArgsUsage: "<domain> [<version>]",
UsageText: "abra app upgrade <domain> [<version>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag,
internal.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
internal.OfflineFlag,
internal.ReleaseNotesFlag,
},
Before: internal.SubCommandBefore,
Description: `
Upgrade an app. You can use it to choose and roll out a new upgrade to an
existing app.
Description: `Upgrade an app.
This command specifically supports incrementing the version of running apps, as
opposed to "abra app deploy <domain>" which will not change the version of a
deployed app.
Unlike "deploy", chaos operations are not supported here. Only recipe versions
are supported values for "[<version>]".
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.
An upgrade can be destructive, please ensure you have a copy of your app data
beforehand.`,
HideHelp: true,
ShellComplete: autocomplete.AppNameComplete,
Action: func(ctx context.Context, cmd *cli.Command) error {
var warnMessages []string
This action could be destructive, please ensure you have a copy of your app
data beforehand.
Chaos mode ("--chaos") will deploy your local checkout of a recipe as-is,
including unstaged changes and can be useful for live hacking and testing new
recipes.
`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
app := internal.ValidateApp(cmd)
stackName := app.StackName()
specificVersion := c.Args().Get(1)
if specificVersion != "" && internal.Chaos {
logrus.Fatal("cannot use <version> and --chaos together")
specificVersion := cmd.Args().Get(1)
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if !internal.Chaos {
if err := recipe.EnsureIsClean(app.Recipe); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipe.EnsureUpToDate(app.Recipe); err != nil {
logrus.Fatal(err)
}
}
if err := recipe.EnsureLatest(app.Recipe); err != nil {
logrus.Fatal(err)
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
recipe, err := recipePkg.Get(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
if err := lint.LintForErrors(app.Recipe); err != nil {
log.Fatal(err)
}
if err := lint.LintForErrors(recipe); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("checking whether %s is already deployed", stackName)
log.Debugf("checking whether %s is already deployed", stackName)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
if !deployMeta.IsDeployed {
log.Fatalf("%s is not deployed?", app.Name)
}
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
versions, err := app.Recipe.Tags()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipePkg.GetRecipeCatalogueVersions(app.Recipe, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 && !internal.Chaos {
logrus.Warn("no published versions in catalogue, trying local recipe repository")
recipeVersions, err := recipePkg.GetRecipeVersions(app.Recipe, internal.Offline)
if err != nil {
logrus.Warn(err)
}
for _, recipeVersion := range recipeVersions {
for version := range recipeVersion {
versions = append(versions, version)
}
}
log.Fatal(err)
}
var availableUpgrades []string
if deployedVersion == "unknown" {
if deployMeta.Version == "unknown" {
availableUpgrades = versions
logrus.Warnf("failed to determine deployed version of %s", app.Name)
warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
}
if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
logrus.Fatal(err)
log.Fatalf("'%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
}
parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
if err != nil {
logrus.Fatal(err)
log.Fatalf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
}
if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) || parsedSpecificVersion.Equals(parsedDeployedVersion) {
logrus.Fatalf("%s is not an upgrade for %s?", deployedVersion, specificVersion)
if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) && !parsedSpecificVersion.Equals(parsedDeployedVersion) {
log.Fatalf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
}
if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
log.Fatalf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
}
availableUpgrades = append(availableUpgrades, specificVersion)
}
parsedDeployedVersion, err := tagcmp.Parse(deployedVersion)
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if deployedVersion != "unknown" && !internal.Chaos && specificVersion == "" {
if deployMeta.Version != "unknown" && specificVersion == "" {
if deployMeta.IsChaos {
warnMessages = append(warnMessages, fmt.Sprintf("attempting to upgrade a chaos deployment"))
}
for _, version := range versions {
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if parsedVersion.IsGreaterThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) {
availableUpgrades = append(availableUpgrades, version)
@ -163,21 +128,27 @@ recipes.
}
if len(availableUpgrades) == 0 && !internal.Force {
logrus.Infof("no available upgrades, you're on latest (%s) ✌️", deployedVersion)
log.Info("no available upgrades")
return nil
}
}
var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos {
if len(availableUpgrades) > 0 {
if internal.Force || internal.NoInput || specificVersion != "" {
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
logrus.Debugf("choosing %s as version to upgrade to", chosenUpgrade)
log.Debugf("choosing %s as version to upgrade to", chosenUpgrade)
} else {
msg := fmt.Sprintf("please select an upgrade (version: %s):", deployMeta.Version)
if deployMeta.IsChaos {
msg = fmt.Sprintf("please select an upgrade (version: %s, chaosVersion: %s):", deployMeta.Version, deployMeta.ChaosVersion)
}
prompt := &survey.Select{
Message: fmt.Sprintf("Please select an upgrade (current version: %s):", deployedVersion),
Message: msg,
Options: internal.ReverseStringList(availableUpgrades),
}
if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
return err
}
@ -185,112 +156,132 @@ recipes.
}
if internal.Force && chosenUpgrade == "" {
logrus.Warnf("%s is already upgraded to latest but continuing (--force/--chaos)", app.Name)
chosenUpgrade = deployedVersion
warnMessages = append(warnMessages, fmt.Sprintf("%s is already upgraded to latest", app.Name))
chosenUpgrade = deployMeta.Version
}
// if release notes written after git tag published, read them before we
// check out the tag and then they'll appear to be missing. this covers
// when we obviously will forget to write release notes before publishing
var releaseNotes string
for _, version := range versions {
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
logrus.Fatal(err)
}
if chosenUpgrade != "" {
parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !(parsedVersion.Equals(parsedDeployedVersion)) && parsedVersion.IsLessThan(parsedChosenUpgrade) {
note, err := internal.GetReleaseNotes(app.Recipe, version)
for _, version := range versions {
parsedVersion, err := tagcmp.Parse(version)
if err != nil {
return err
log.Fatal(err)
}
if note != "" {
releaseNotes += fmt.Sprintf("%s\n", note)
if parsedVersion.IsGreaterThan(parsedDeployedVersion) && parsedVersion.IsLessThan(parsedChosenUpgrade) {
note, err := app.Recipe.GetReleaseNotes(version)
if err != nil {
return err
}
if note != "" {
releaseNotes += fmt.Sprintf("%s\n", note)
}
}
}
}
if !internal.Chaos {
if err := recipePkg.EnsureVersion(app.Recipe, chosenUpgrade); err != nil {
logrus.Fatal(err)
}
log.Debugf("choosing %s as version to upgrade", chosenUpgrade)
if _, err := app.Recipe.EnsureVersion(chosenUpgrade); err != nil {
log.Fatal(err)
}
if internal.Chaos {
logrus.Warn("chaos mode engaged")
var err error
chosenUpgrade, err = recipePkg.ChaosVersion(app.Recipe)
if err != nil {
logrus.Fatal(err)
}
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Recipe, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for k, v := range abraShEnv {
app.Env[k] = v
}
composeFiles, err := config.GetComposeFiles(app.Recipe, app.Env)
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := config.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
}
config.ExposeAllEnv(stackName, compose, app.Env)
config.SetRecipeLabel(compose, stackName, app.Recipe)
config.SetChaosLabel(compose, stackName, internal.Chaos)
config.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
config.SetUpdateLabel(compose, stackName, app.Env)
envVars, err := config.CheckEnv(app)
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
appPkg.ExposeAllEnv(stackName, compose, app.Env)
appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
appPkg.SetUpdateLabel(compose, stackName, app.Env)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
log.Fatal(err)
}
for _, envVar := range envVars {
if !envVar.Present {
logrus.Warnf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain)
warnMessages = append(warnMessages,
fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
)
}
}
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil {
logrus.Fatal(err)
if internal.ReleaseNotes {
fmt.Println()
fmt.Print(releaseNotes)
return nil
}
stack.WaitTimeout, err = config.GetTimeoutFromLabel(compose, stackName)
if err != nil {
logrus.Fatal(err)
chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion
}
logrus.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
if err := internal.NewVersionOverview(
app,
warnMessages,
"upgrade",
deployMeta.Version,
chaosVersion,
chosenUpgrade,
releaseNotes); err != nil {
log.Fatal(err)
}
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
if err != nil {
log.Fatal(err)
}
log.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
if ok && !internal.DontWaitConverge {
logrus.Debugf("run the following post-deploy commands: %s", postDeployCmds)
log.Debugf("run the following post-deploy commands: %s", postDeployCmds)
if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
logrus.Fatalf("attempting to run post deploy commands, saw: %s", err)
log.Fatalf("attempting to run post deploy commands, saw: %s", err)
}
}
app.Recipe.Version = chosenUpgrade
log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err)
}
return nil
},
}

View File

@ -1,117 +0,0 @@
package app
import (
"context"
"sort"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/distribution/reference"
"github.com/olekukonko/tablewriter"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
)
func sortServiceByName(versions [][]string) func(i, j int) bool {
return func(i, j int) bool {
// NOTE(d1): corresponds to the `tableCol` definition below
if versions[i][1] == "app" {
return true
}
return versions[i][1] < versions[j][1]
}
}
// getImagePath returns the image name
func getImagePath(image string) (string, error) {
img, err := reference.ParseNormalizedNamed(image)
if err != nil {
return "", err
}
path := reference.Path(img)
path = formatter.StripTagMeta(path)
logrus.Debugf("parsed %s from %s", path, image)
return path, nil
}
var appVersionCommand = cli.Command{
Name: "version",
Aliases: []string{"v"},
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Usage: "Show version info of a deployed app",
BashComplete: autocomplete.AppNameComplete,
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(context.Background(), cl, stackName)
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
if deployedVersion == "unknown" {
logrus.Fatalf("failed to determine version of deployed %s", app.Name)
}
recipeMeta, err := recipe.GetRecipeMeta(app.Recipe, internal.Offline)
if err != nil {
logrus.Fatal(err)
}
versionsMeta := make(map[string]recipe.ServiceMeta)
for _, recipeVersion := range recipeMeta.Versions {
if currentVersion, exists := recipeVersion[deployedVersion]; exists {
versionsMeta = currentVersion
}
}
if len(versionsMeta) == 0 {
logrus.Fatalf("could not retrieve deployed version (%s) from recipe catalogue?", deployedVersion)
}
tableCol := []string{"version", "service", "image", "tag"}
table := formatter.CreateTable(tableCol)
var versions [][]string
for serviceName, versionMeta := range versionsMeta {
versions = append(versions, []string{deployedVersion, serviceName, versionMeta.Image, versionMeta.Tag})
}
sort.Slice(versions, sortServiceByName(versions))
for _, version := range versions {
table.Append(version)
}
table.SetAutoMergeCellsByColumnIndex([]int{0})
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.Render()
return nil
},
}

View File

@ -2,61 +2,68 @@ package app
import (
"context"
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var appVolumeListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
ArgsUsage: "<domain>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
Usage: "List volumes associated with an app",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Name: "list",
Aliases: []string{"ls"},
UsageText: "abra app volume list <domain> [options]",
Before: internal.SubCommandBefore,
Usage: "List volumes associated with an app",
ShellComplete: autocomplete.AppNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
filters, err := app.Filters(false, true)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
volumes, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
table := formatter.CreateTable([]string{"name", "created", "mounted"})
var volTable [][]string
for _, volume := range volumeList {
volRow := []string{volume.Name, volume.CreatedAt, volume.Mountpoint}
volTable = append(volTable, volRow)
headers := []string{"name", "created", "mounted"}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.AppendBulk(volTable)
table.Headers(headers...)
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Warnf("no volumes created for %s", app.Name)
var rows [][]string
for _, volume := range volumes {
row := []string{volume.Name, volume.CreatedAt, volume.Mountpoint}
rows = append(rows, row)
}
table.Rows(rows...)
if len(rows) > 0 {
fmt.Println(table)
return nil
}
log.Warnf("no volumes created for %s", app.Name)
return nil
},
}
@ -64,51 +71,49 @@ var appVolumeListCommand = cli.Command{
var appVolumeRemoveCommand = cli.Command{
Name: "remove",
Usage: "Remove volume(s) associated with an app",
Description: `
This command supports removing volumes associated with an app. The app in
question must be undeployed before you try to remove volumes. See "abra app
undeploy <domain>" for more.
Description: `Remove volumes associated with an app.
The app in question must be undeployed before you try to remove volumes. See
"abra app undeploy <domain>" for more.
The command is interactive and will show a multiple select input which allows
you to make a seclection. Use the "?" key to see more help on navigating this
interface.
Passing "--force/-f" will select all volumes for removal. Be careful.
`,
ArgsUsage: "<domain>",
Passing "--force/-f" will select all volumes for removal. Be careful.`,
UsageText: "abra app volume remove [options] <domain>",
Aliases: []string{"rm"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.ForceFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.AppNameComplete,
Action: func(ctx context.Context, cmd *cli.Command) error {
app := internal.ValidateApp(cmd)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(context.Background(), cl, app.StackName())
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
if deployMeta.IsDeployed {
log.Fatalf("%s is still deployed. Run \"abra app undeploy %s\"", app.Name, app.Name)
}
filters, err := app.Filters(false, true)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volumeNames := client.GetVolumeNames(volumeList)
@ -122,7 +127,7 @@ Passing "--force/-f" will select all volumes for removal. Be careful.
Default: volumeNames,
}
if err := survey.AskOne(volumesPrompt, &volumesToRemove); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -131,14 +136,14 @@ Passing "--force/-f" will select all volumes for removal. Be careful.
}
if len(volumesToRemove) > 0 {
err = client.RemoveVolumes(cl, context.Background(), app.Server, volumesToRemove, internal.Force)
err := client.RemoveVolumes(cl, context.Background(), volumesToRemove, internal.Force, 5)
if err != nil {
logrus.Fatal(err)
log.Fatalf("removing volumes failed: %s", err)
}
logrus.Info("volumes removed successfully")
log.Infof("%d volumes removed successfully", len(volumesToRemove))
} else {
logrus.Info("no volumes removed")
log.Info("no volumes removed")
}
return nil
@ -149,9 +154,9 @@ var appVolumeCommand = cli.Command{
Name: "volume",
Aliases: []string{"vl"},
Usage: "Manage app volumes",
ArgsUsage: "<domain>",
Subcommands: []cli.Command{
appVolumeListCommand,
appVolumeRemoveCommand,
UsageText: "abra app volume [command] [options] [arguments]",
Commands: []*cli.Command{
&appVolumeListCommand,
&appVolumeRemoveCommand,
},
}

View File

@ -1,6 +1,7 @@
package catalogue
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
@ -12,35 +13,25 @@ import (
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var catalogueGenerateCommand = cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate the recipe catalogue",
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate the recipe catalogue",
UsageText: "abra catalogue generate [<recipe>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.PublishFlag,
internal.DryFlag,
internal.SkipUpdatesFlag,
internal.ChaosFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
Generate a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech (website that humans read)
https://recipes.coopcloud.tech/recipes.json (JSON that Abra reads)
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README.md and git tags to produce recipe metadata which is
loaded into the catalogue JSON file.
Description: `Generate a new copy of the recipe catalogue.
It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten.
@ -51,26 +42,26 @@ If you have a Hub account you can have Abra log you in to avoid this. Pass
Push your new release to git.coopcloud.tech with "-p/--publish". This requires
that you have permission to git push to these repositories and have your SSH
keys configured on your account.
`,
ArgsUsage: "[<recipe>]",
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
keys configured on your account.`,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipeName := cmd.Args().First()
r := recipe.Get(recipeName)
if recipeName != "" {
internal.ValidateRecipe(c)
internal.ValidateRecipe(cmd)
}
if !internal.Chaos {
if err := catalogue.EnsureIsClean(); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
repos, err := recipe.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var barLength int
@ -84,9 +75,9 @@ keys configured on your account.
}
if !internal.SkipUpdates {
logrus.Warn(logMsg)
log.Warn(logMsg)
if err := recipe.UpdateRepositories(repos, recipeName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -98,14 +89,14 @@ keys configured on your account.
continue
}
versions, err := recipe.GetRecipeVersions(recipeMeta.Name, internal.Offline)
versions, err := r.GetRecipeVersions()
if err != nil {
logrus.Warn(err)
log.Warn(err)
}
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
features, category, err := recipe.GetRecipeFeaturesAndCategory(r)
if err != nil {
logrus.Warn(err)
log.Warn(err)
}
catl[recipeMeta.Name] = recipe.RecipeMeta{
@ -126,84 +117,84 @@ keys configured on your account.
recipesJSON, err := json.MarshalIndent(catl, "", " ")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if recipeName == "" {
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
} else {
catlFS, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
logrus.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
log.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
if internal.Publish {
isClean, err := gitPkg.IsClean(cataloguePath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if isClean {
if !internal.Dry {
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
log.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
}
}
msg := "chore: publish new catalogue release changes"
if err := gitPkg.Commit(cataloguePath, msg, internal.Dry); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
head, err := repo.Head()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !internal.Dry && internal.Publish {
url := fmt.Sprintf("%s/%s/commit/%s", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME, head.Hash())
logrus.Infof("new changes published: %s", url)
log.Infof("new changes published: %s", url)
}
if internal.Dry {
logrus.Info("dry run: no changes published")
log.Info("dry run: no changes published")
}
return nil
@ -212,12 +203,11 @@ keys configured on your account.
// CatalogueCommand defines the `abra catalogue` command and sub-commands.
var CatalogueCommand = cli.Command{
Name: "catalogue",
Usage: "Manage the recipe catalogue",
Aliases: []string{"c"},
ArgsUsage: "<recipe>",
Description: "This command helps recipe packagers interact with the recipe catalogue",
Subcommands: []cli.Command{
catalogueGenerateCommand,
Name: "catalogue",
Usage: "Manage the recipe catalogue",
Aliases: []string{"c"},
UsageText: "abra catalogue [command] [options] [arguments]",
Commands: []*cli.Command{
&catalogueGenerateCommand,
},
}

View File

@ -2,6 +2,7 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
@ -14,36 +15,28 @@ import (
"coopcloud.tech/abra/cli/recipe"
"coopcloud.tech/abra/cli/server"
"coopcloud.tech/abra/pkg/autocomplete"
cataloguePkg "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/web"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
charmLog "github.com/charmbracelet/log"
"github.com/urfave/cli/v3"
)
// AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = cli.Command{
Name: "autocomplete",
Aliases: []string{"ac"},
Usage: "Configure shell autocompletion (recommended)",
Description: `
Set up auto-completion in your shell by downloading the relevant files and
laying out what additional information must be loaded. Supported shells are as
follows: bash, fish, fizsh & zsh.
Name: "autocomplete",
Aliases: []string{"ac"},
Usage: "Configure shell autocompletion",
UsageText: "abra autocomplete <shell> [options]",
Description: `Set up shell auto-completion.
Example:
abra autocomplete bash
`,
ArgsUsage: "<shell>",
Flags: []cli.Flag{
internal.DebugFlag,
},
Action: func(c *cli.Context) error {
shellType := c.Args().First()
Supported shells are: bash, fish, fizsh & zsh.`,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
shellType := cmd.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
internal.ShowSubcommandHelpAndError(cmd, errors.New("no shell provided"))
}
supportedShells := map[string]bool{
@ -54,7 +47,7 @@ Example:
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
log.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fizsh" {
@ -64,44 +57,47 @@ Example:
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("%s already created", autocompletionDir)
log.Debugf("%s already created", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
log.Infof("fetching %s", url)
if err := web.GetFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
sudo mkdir /etc/bash_completion.d/
# run the following commands once to install auto-completion
sudo mkdir -p /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
source /etc/bash_completion.d/abra
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
sudo mkdir /etc/zsh/completion.d/
# run the following commands to once install auto-completion
sudo mkdir -p /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
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
source /etc/zsh/completion.d/abra
# to test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
case "fish":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install auto-completion
# run the following commands once to install auto-completion
sudo mkdir -p /etc/fish/completions
sudo cp %s /etc/fish/completions/abra
echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish
# To test, run the following: "abra app <hit tab key>" - you should see command completion!
source /etc/fish/completions/abra
# to test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile))
}
@ -111,62 +107,61 @@ echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade Abra itself",
Description: `
Upgrade Abra in-place with the latest stable or release candidate.
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade abra",
UsageText: "abra upgrade [options]",
Description: `Upgrade abra in-place with the latest stable or release candidate.
Pass "-r/--rc" to install the latest release candidate. Please bear in mind
that it may contain catastrophic bugs. Thank you very much for the testing
efforts!
`,
Flags: []cli.Flag{internal.RCFlag},
Action: func(c *cli.Context) error {
Use "--rc/-r" to install the latest release candidate. Please bear in mind that
it may contain absolutely catastrophic deal-breaker bugs. Thank you very much
for the testing efforts 💗`,
Flags: []cli.Flag{internal.RCFlag},
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
mainURL := "https://install.abra.coopcloud.tech"
cmd := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
c := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
if internal.RC {
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
cmd = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
c = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
}
logrus.Debugf("attempting to run %s", cmd)
log.Debugf("attempting to run %s", c)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
if err := internal.RunCmd(c); err != nil {
log.Fatal(err)
}
return nil
},
}
func newAbraApp(version, commit string) *cli.App {
app := &cli.App{
Name: "abra",
Usage: `The Co-op Cloud command-line utility belt 🎩🐇
____ ____ _ _
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|_|
`,
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []cli.Command{
app.AppCommand,
server.ServerCommand,
recipe.RecipeCommand,
catalogue.CatalogueCommand,
UpgradeCommand,
AutoCompleteCommand,
func newAbraApp(version, commit string) *cli.Command {
app := &cli.Command{
Name: "abra",
Usage: "The Co-op Cloud command-line utility belt 🎩🐇",
UsageText: "abra [command] [arguments] [options]",
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Flags: []cli.Flag{
// NOTE(d1): "GLOBAL OPTIONS" flags
internal.DebugFlag,
},
BashComplete: autocomplete.SubcommandComplete,
Commands: []*cli.Command{
&app.AppCommand,
&server.ServerCommand,
&recipe.RecipeCommand,
&catalogue.CatalogueCommand,
&UpgradeCommand,
&AutoCompleteCommand,
},
EnableShellCompletion: true,
UseShortOptionHandling: true,
HideHelpCommand: true,
ShellComplete: autocomplete.SubcommandComplete,
}
app.EnableBashCompletion = true
app.Before = func(c *cli.Context) error {
app.Before = func(ctx context.Context, cmd *cli.Command) error {
paths := []string{
config.ABRA_DIR,
config.SERVERS_DIR,
@ -178,21 +173,26 @@ func newAbraApp(version, commit string) *cli.App {
for _, path := range paths {
if err := os.Mkdir(path, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
log.Fatal(err)
}
continue
}
}
if err := cataloguePkg.EnsureCatalogue(); err != nil {
logrus.Fatal(err)
}
log.Logger.SetStyles(log.Styles())
charmLog.SetDefault(log.Logger)
logrus.Debugf("abra version %s, commit %s", version, commit)
log.Debugf("abra version %s, commit %s", version, commit)
return nil
}
cli.HelpFlag = &cli.BoolFlag{
Name: "help",
Aliases: []string{"h, H"},
Usage: "Show help",
}
return app
}
@ -200,7 +200,7 @@ func newAbraApp(version, commit string) *cli.App {
func RunApp(version, commit string) {
app := newAbraApp(version, commit)
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
if err := app.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}

View File

@ -1,35 +1,67 @@
package internal
import (
"strings"
"context"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/service"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
)
// SafeSplit splits up a string into a list of commands safely.
func SafeSplit(s string) []string {
split := strings.Split(s, " ")
var result []string
var inquote string
var block string
for _, i := range split {
if inquote == "" {
if strings.HasPrefix(i, "'") || strings.HasPrefix(i, "\"") {
inquote = string(i[0])
block = strings.TrimPrefix(i, inquote) + " "
} else {
result = append(result, i)
}
} else {
if !strings.HasSuffix(i, inquote) {
block += i + " "
} else {
block += strings.TrimSuffix(i, inquote)
inquote = ""
result = append(result, block)
block = ""
}
}
// RetrieveBackupBotContainer gets the deployed backupbot container.
func RetrieveBackupBotContainer(cl *dockerClient.Client) (types.Container, error) {
ctx := context.Background()
chosenService, err := service.GetServiceByLabel(ctx, cl, config.BackupbotLabel, NoInput)
if err != nil {
return types.Container{}, err
}
return result
log.Debugf("retrieved %s as backup enabled service", chosenService.Spec.Name)
filters := filters.NewArgs()
filters.Add("name", chosenService.Spec.Name)
targetContainer, err := containerPkg.GetContainer(
ctx,
cl,
filters,
NoInput,
)
if err != nil {
return types.Container{}, err
}
return targetContainer, nil
}
// RunBackupCmdRemote runs a backup related command on a remote backupbot container.
func RunBackupCmdRemote(cl *dockerClient.Client, backupCmd string, containerID string, execEnv []string) error {
execBackupListOpts := types.ExecConfig{
AttachStderr: true,
AttachStdin: true,
AttachStdout: true,
Cmd: []string{"/usr/bin/backup", "--", backupCmd},
Detach: false,
Env: execEnv,
Tty: true,
}
log.Debugf("running backup %s on %s with exec config %v", backupCmd, containerID, execBackupListOpts)
// FIXME: avoid instantiating a new CLI
dcli, err := command.NewDockerCli()
if err != nil {
return err
}
if _, err := container.RunExec(dcli, cl, containerID, &execBackupListOpts); err != nil {
return err
}
return nil
}

View File

@ -1,11 +1,11 @@
package internal
import (
"context"
"os"
logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
// Secrets stores the variable from SecretsFlag
@ -13,7 +13,8 @@ var Secrets bool
// SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{
Name: "secrets, S",
Name: "secrets",
Aliases: []string{"S"},
Usage: "Automatically generate secrets",
Destination: &Secrets,
}
@ -23,7 +24,8 @@ var Pass bool
// PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{
Name: "pass, p",
Name: "pass",
Aliases: []string{"p"},
Usage: "Store the generated secrets in a local pass store",
Destination: &Pass,
}
@ -33,17 +35,35 @@ var PassRemove bool
// PassRemoveFlag turns on/off removing generated secrets from pass
var PassRemoveFlag = &cli.BoolFlag{
Name: "pass, p",
Name: "pass",
Aliases: []string{"p"},
Usage: "Remove generated secrets from a local pass store",
Destination: &PassRemove,
}
var File bool
var FileFlag = &cli.BoolFlag{
Name: "file",
Aliases: []string{"f"},
Usage: "Treat input as a file",
Destination: &File,
}
var Trim bool
var TrimFlag = &cli.BoolFlag{
Name: "trim",
Aliases: []string{"t"},
Usage: "Trim input",
Destination: &Trim,
}
// Force force functionality without asking.
var Force bool
// ForceFlag turns on/off force functionality.
var ForceFlag = &cli.BoolFlag{
Name: "force, f",
Name: "force",
Aliases: []string{"f"},
Usage: "Perform action without further prompt. Use with care!",
Destination: &Force,
}
@ -53,8 +73,9 @@ var Chaos bool
// ChaosFlag turns on/off chaos functionality.
var ChaosFlag = &cli.BoolFlag{
Name: "chaos, C",
Usage: "Proceed with uncommitted recipes changes. Use with care!",
Name: "chaos",
Aliases: []string{"C"},
Usage: "Ignore uncommitted recipes changes. Use with care!",
Destination: &Chaos,
}
@ -63,14 +84,16 @@ var Tty bool
// TtyFlag turns on/off tty mode.
var TtyFlag = &cli.BoolFlag{
Name: "tty, T",
Name: "tty",
Aliases: []string{"T"},
Usage: "Disables TTY mode to run this command from a script.",
Destination: &Tty,
}
var NoInput bool
var NoInputFlag = &cli.BoolFlag{
Name: "no-input, n",
Name: "no-input",
Aliases: []string{"n"},
Usage: "Toggle non-interactive mode",
Destination: &NoInput,
}
@ -80,7 +103,8 @@ var Debug bool
// DebugFlag turns on/off verbose logging down to the DEBUG level.
var DebugFlag = &cli.BoolFlag{
Name: "debug, d",
Name: "debug",
Aliases: []string{"d"},
Destination: &Debug,
Usage: "Show DEBUG messages",
}
@ -90,9 +114,21 @@ var Offline bool
// DebugFlag turns on/off offline mode.
var OfflineFlag = &cli.BoolFlag{
Name: "offline, o",
Name: "offline",
Aliases: []string{"o"},
Destination: &Offline,
Usage: "Prefer offline & filesystem access when possible",
Usage: "Prefer offline & filesystem access",
}
// ReleaseNotes stores the variable from ReleaseNotesFlag.
var ReleaseNotes bool
// ReleaseNotesFlag turns on/off printing only release notes when upgrading.
var ReleaseNotesFlag = &cli.BoolFlag{
Name: "releasenotes",
Aliases: []string{"r"},
Destination: &ReleaseNotes,
Usage: "Only show release notes",
}
// MachineReadable stores the variable from MachineReadableFlag
@ -100,9 +136,10 @@ var MachineReadable bool
// MachineReadableFlag turns on/off machine readable output where supported
var MachineReadableFlag = &cli.BoolFlag{
Name: "machine, m",
Name: "machine",
Aliases: []string{"m"},
Destination: &MachineReadable,
Usage: "Output in a machine-readable format (where supported)",
Usage: "Machine-readable output",
}
// RC signifies the latest release candidate
@ -110,49 +147,56 @@ var RC bool
// RCFlag chooses the latest release candidate for install
var RCFlag = &cli.BoolFlag{
Name: "rc, r",
Name: "rc",
Aliases: []string{"r"},
Destination: &RC,
Usage: "Install the latest release candidate",
}
var Major bool
var MajorFlag = &cli.BoolFlag{
Name: "major, x",
Name: "major",
Aliases: []string{"x"},
Usage: "Increase the major part of the version",
Destination: &Major,
}
var Minor bool
var MinorFlag = &cli.BoolFlag{
Name: "minor, y",
Name: "minor",
Aliases: []string{"y"},
Usage: "Increase the minor part of the version",
Destination: &Minor,
}
var Patch bool
var PatchFlag = &cli.BoolFlag{
Name: "patch, z",
Name: "patch",
Aliases: []string{"z"},
Usage: "Increase the patch part of the version",
Destination: &Patch,
}
var Dry bool
var DryFlag = &cli.BoolFlag{
Name: "dry-run, r",
Name: "dry-run",
Aliases: []string{"r"},
Usage: "Only reports changes that would be made",
Destination: &Dry,
}
var Publish bool
var PublishFlag = &cli.BoolFlag{
Name: "publish, p",
Name: "publish",
Aliases: []string{"p"},
Usage: "Publish changes to git.coopcloud.tech",
Destination: &Publish,
}
var Domain string
var DomainFlag = &cli.StringFlag{
Name: "domain, D",
Name: "domain",
Aliases: []string{"D"},
Value: "",
Usage: "Choose a domain name",
Destination: &Domain,
@ -160,7 +204,8 @@ var DomainFlag = &cli.StringFlag{
var NewAppServer string
var NewAppServerFlag = &cli.StringFlag{
Name: "server, s",
Name: "server",
Aliases: []string{"s"},
Value: "",
Usage: "Show apps of a specific server",
Destination: &NewAppServer,
@ -168,21 +213,24 @@ var NewAppServerFlag = &cli.StringFlag{
var NoDomainChecks bool
var NoDomainChecksFlag = &cli.BoolFlag{
Name: "no-domain-checks, D",
Usage: "Disable app domain sanity checks",
Name: "no-domain-checks",
Aliases: []string{"D"},
Usage: "Disable public DNS checks",
Destination: &NoDomainChecks,
}
var StdErrOnly bool
var StdErrOnlyFlag = &cli.BoolFlag{
Name: "stderr, s",
Name: "stderr",
Aliases: []string{"s"},
Usage: "Only tail stderr",
Destination: &StdErrOnly,
}
var SinceLogs string
var SinceLogsFlag = &cli.StringFlag{
Name: "since, S",
Name: "since",
Aliases: []string{"S"},
Value: "",
Usage: "tail logs since YYYY-MM-DDTHH:MM:SSZ",
Destination: &SinceLogs,
@ -190,61 +238,93 @@ var SinceLogsFlag = &cli.StringFlag{
var DontWaitConverge bool
var DontWaitConvergeFlag = &cli.BoolFlag{
Name: "no-converge-checks, c",
Name: "no-converge-checks",
Aliases: []string{"c"},
Usage: "Don't wait for converge logic checks",
Destination: &DontWaitConverge,
}
var Watch bool
var WatchFlag = &cli.BoolFlag{
Name: "watch, w",
Name: "watch",
Aliases: []string{"w"},
Usage: "Watch status by polling repeatedly",
Destination: &Watch,
}
var OnlyErrors bool
var OnlyErrorFlag = &cli.BoolFlag{
Name: "errors, e",
Name: "errors",
Aliases: []string{"e"},
Usage: "Only show errors",
Destination: &OnlyErrors,
}
var SkipUpdates bool
var SkipUpdatesFlag = &cli.BoolFlag{
Name: "skip-updates, s",
Name: "skip-updates",
Aliases: []string{"s"},
Usage: "Skip updating recipe repositories",
Destination: &SkipUpdates,
}
var AllTags bool
var AllTagsFlag = &cli.BoolFlag{
Name: "all-tags, a",
Name: "all-tags",
Aliases: []string{"a"},
Usage: "List all tags, not just upgrades",
Destination: &AllTags,
}
var LocalCmd bool
var LocalCmdFlag = &cli.BoolFlag{
Name: "local, l",
Name: "local",
Aliases: []string{"l"},
Usage: "Run command locally",
Destination: &LocalCmd,
}
var RemoteUser string
var RemoteUserFlag = &cli.StringFlag{
Name: "user, u",
Name: "user",
Aliases: []string{"u"},
Value: "",
Usage: "User to run command within a service context",
Destination: &RemoteUser,
}
var GitName string
var GitNameFlag = &cli.StringFlag{
Name: "git-name",
Aliases: []string{"gn"},
Value: "",
Usage: "Git (user) name to do commits with",
Destination: &GitName,
}
var GitEmail string
var GitEmailFlag = &cli.StringFlag{
Name: "git-email",
Aliases: []string{"ge"},
Value: "",
Usage: "Git email name to do commits with",
Destination: &GitEmail,
}
var AllServices bool
var AllServicesFlag = &cli.BoolFlag{
Name: "all-services",
Aliases: []string{"a"},
Usage: "Restart all services",
Destination: &AllServices,
}
// SubCommandBefore wires up pre-action machinery (e.g. --debug handling).
func SubCommandBefore(c *cli.Context) error {
func SubCommandBefore(ctx context.Context, cmd *cli.Command) error {
if Debug {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
logrus.AddHook(logrusStack.StandardHook())
log.SetLevel(log.DebugLevel)
log.SetOutput(os.Stderr)
log.SetReportCaller(true)
}
return nil

View File

@ -8,20 +8,20 @@ import (
"os/exec"
"strings"
"coopcloud.tech/abra/pkg/config"
appPkg "coopcloud.tech/abra/pkg/app"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus"
)
// RunCmdRemote executes an abra.sh command in the target service
func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName, cmdName, cmdArgs string) error {
func RunCmdRemote(cl *dockerClient.Client, app appPkg.App, abraSh, serviceName, cmdName, cmdArgs string) error {
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
@ -30,7 +30,7 @@ func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName,
return err
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(targetContainer.ID), app.Server)
log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(targetContainer.ID), app.Server)
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(abraSh, toTarOpts)
@ -60,8 +60,8 @@ func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName,
Tty: false,
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
logrus.Infof("%s does not exist for %s, use /bin/sh as fallback", shell, app.Name)
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
log.Infof("%s does not exist for %s, use /bin/sh as fallback", shell, app.Name)
shell = "/bin/sh"
}
@ -72,10 +72,10 @@ func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName,
cmd = []string{shell, "-c", fmt.Sprintf("TARGET=%s; APP_NAME=%s; STACK_NAME=%s; . /tmp/abra.sh; %s", serviceName, app.Name, app.StackName(), cmdName)}
}
logrus.Debugf("running command: %s", strings.Join(cmd, " "))
log.Debugf("running command: %s", strings.Join(cmd, " "))
if RemoteUser != "" {
logrus.Debugf("running command with user %s", RemoteUser)
log.Debugf("running command with user %s", RemoteUser)
execCreateOpts.User = RemoteUser
}
@ -85,7 +85,7 @@ func RunCmdRemote(cl *dockerClient.Client, app config.App, abraSh, serviceName,
execCreateOpts.Tty = false
}
if err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
if _, err := container.RunExec(dcli, cl, targetContainer.ID, &execCreateOpts); err != nil {
return err
}

View File

@ -2,23 +2,45 @@ package internal
import (
"fmt"
"io/ioutil"
"os"
"path"
"strings"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/log"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/lipgloss"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error {
tableCol := []string{"server", "recipe", "config", "domain", "current version", "to be deployed"}
table := formatter.CreateTable(tableCol)
var borderStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
Padding(0, 1, 0, 1).
MaxWidth(79).
BorderForeground(lipgloss.Color("63"))
var headerStyle = lipgloss.NewStyle().
Underline(true).
Bold(true)
var leftStyle = lipgloss.NewStyle().
Bold(true)
var rightStyle = lipgloss.NewStyle()
// horizontal is a JoinHorizontal helper function.
func horizontal(left, mid, right string) string {
return lipgloss.JoinHorizontal(lipgloss.Left, left, mid, right)
}
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(
app appPkg.App,
warnMessages []string,
kind,
currentVersion,
chaosVersion,
newVersion,
releaseNotes string) error {
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
@ -29,14 +51,36 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
server = "local"
}
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, currentVersion, newVersion})
table.Render()
body := strings.Builder{}
body.WriteString(
borderStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
headerStyle.Render(fmt.Sprintf("%s OVERVIEW", strings.ToUpper(kind))),
lipgloss.JoinVertical(
lipgloss.Left,
horizontal(leftStyle.Render("SERVER"), " ", rightStyle.Render(server)),
horizontal(leftStyle.Render("DOMAIN"), " ", rightStyle.Render(app.Domain)),
horizontal(leftStyle.Render("RECIPE"), " ", rightStyle.Render(app.Recipe.Name)),
horizontal(leftStyle.Render("CONFIG"), " ", rightStyle.Render(deployConfig)),
horizontal(leftStyle.Render("VERSION"), " ", rightStyle.Render(currentVersion)),
horizontal(leftStyle.Render("CHAOS"), " ", rightStyle.Render(chaosVersion)),
horizontal(leftStyle.Render("DEPLOY"), " ", rightStyle.Padding(0).Render(newVersion)),
),
),
),
)
fmt.Println(body.String())
if releaseNotes != "" && newVersion != "" {
fmt.Println()
fmt.Print(releaseNotes)
} else {
logrus.Warnf("no release notes available for %s", newVersion)
warnMessages = append(warnMessages, fmt.Sprintf("no release notes available for %s", newVersion))
}
for _, msg := range warnMessages {
log.Warn(msg)
}
if NoInput {
@ -44,49 +88,78 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
}
response := false
prompt := &survey.Confirm{
Message: "continue with deployment?",
}
prompt := &survey.Confirm{Message: "proceed?"}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
log.Fatal("deployment cancelled")
}
return nil
}
// GetReleaseNotes prints release notes for a recipe version
func GetReleaseNotes(recipeName, version string) (string, error) {
if version == "" {
return "", nil
// DeployOverview shows a deployment overview
func DeployOverview(app appPkg.App, warnMessages []string, version, chaosVersion string) error {
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
fpath := path.Join(config.RECIPES_DIR, recipeName, "release", version)
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
releaseNotes, err := ioutil.ReadFile(fpath)
if err != nil {
return "", err
}
withTitle := fmt.Sprintf("%s release notes:\n%s", version, string(releaseNotes))
return withTitle, nil
server := app.Server
if app.Server == "default" {
server = "local"
}
return "", nil
body := strings.Builder{}
body.WriteString(
borderStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
headerStyle.Render("DEPLOY OVERVIEW"),
lipgloss.JoinVertical(
lipgloss.Left,
horizontal(leftStyle.Render("SERVER"), " ", rightStyle.Render(server)),
horizontal(leftStyle.Render("DOMAIN"), " ", rightStyle.Render(app.Domain)),
horizontal(leftStyle.Render("RECIPE"), " ", rightStyle.Render(app.Recipe.Name)),
horizontal(leftStyle.Render("CONFIG"), " ", rightStyle.Render(deployConfig)),
horizontal(leftStyle.Render("VERSION"), " ", rightStyle.Render(version)),
horizontal(leftStyle.Render("CHAOS"), " ", rightStyle.Padding(0).Render(chaosVersion)),
),
),
),
)
fmt.Println(body.String())
for _, msg := range warnMessages {
log.Warn(msg)
}
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{Message: "proceed?"}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
log.Fatal("deployment cancelled")
}
return nil
}
// PostCmds parses a string of commands and executes them inside of the respective services
// the commands string must have the following format:
// "<service> <command> <arguments>|<service> <command> <arguments>|... "
func PostCmds(cl *dockerClient.Client, app config.App, commands string) error {
abraSh := path.Join(config.RECIPES_DIR, app.Recipe, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
func PostCmds(cl *dockerClient.Client, app appPkg.App, commands string) error {
if _, err := os.Stat(app.Recipe.AbraShPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf(fmt.Sprintf("%s does not exist for %s?", abraSh, app.Name))
return fmt.Errorf(fmt.Sprintf("%s does not exist for %s?", app.Recipe.AbraShPath, app.Name))
}
return err
}
@ -102,13 +175,13 @@ func PostCmds(cl *dockerClient.Client, app config.App, commands string) error {
if len(commandParts) > 2 {
parsedCmdArgs = fmt.Sprintf("%s ", strings.Join(commandParts[2:], " "))
}
logrus.Infof("running post-command '%s %s' in container %s", cmdName, parsedCmdArgs, targetServiceName)
log.Infof("running post-command '%s %s' in container %s", cmdName, parsedCmdArgs, targetServiceName)
if err := EnsureCommand(abraSh, app.Recipe, cmdName); err != nil {
if err := EnsureCommand(app.Recipe.AbraShPath, app.Recipe.Name, cmdName); err != nil {
return err
}
serviceNames, err := config.GetAppServiceNames(app.Name)
serviceNames, err := appPkg.GetAppServiceNames(app.Name)
if err != nil {
return err
}
@ -124,50 +197,12 @@ func PostCmds(cl *dockerClient.Client, app config.App, commands string) error {
return fmt.Errorf(fmt.Sprintf("no service %s for %s?", targetServiceName, app.Name))
}
logrus.Debugf("running command %s %s within the context of %s_%s", cmdName, parsedCmdArgs, app.StackName(), targetServiceName)
log.Debugf("running command %s %s within the context of %s_%s", cmdName, parsedCmdArgs, app.StackName(), targetServiceName)
Tty = true
if err := RunCmdRemote(cl, app, abraSh, targetServiceName, cmdName, parsedCmdArgs); err != nil {
if err := RunCmdRemote(cl, app, app.Recipe.AbraShPath, targetServiceName, cmdName, parsedCmdArgs); err != nil {
return err
}
}
return nil
}
// DeployOverview shows a deployment overview
func DeployOverview(app config.App, version, message string) error {
tableCol := []string{"server", "recipe", "config", "domain", "version"}
table := formatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
table.Append([]string{server, app.Recipe, deployConfig, app.Domain, version})
table.Render()
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{
Message: message,
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
return nil
}

View File

@ -3,16 +3,16 @@ package internal
import (
"os"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
// ShowSubcommandHelpAndError exits the program on error, logs the error to the
// terminal, and shows the help command.
func ShowSubcommandHelpAndError(c *cli.Context, err interface{}) {
if err2 := cli.ShowSubcommandHelp(c); err2 != nil {
logrus.Error(err2)
func ShowSubcommandHelpAndError(cmd *cli.Command, err interface{}) {
if err2 := cli.ShowSubcommandHelp(cmd); err2 != nil {
log.Error(err2)
}
logrus.Error(err)
log.Error(err)
os.Exit(1)
}

View File

@ -4,10 +4,10 @@ import (
"fmt"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
"github.com/distribution/reference"
)
// PromptBumpType prompts for version bump type
@ -65,7 +65,7 @@ func GetBumpType() string {
} else if Patch {
bumpType = "patch"
} else {
logrus.Fatal("no version bump type specififed?")
log.Fatal("no version bump type specififed?")
}
return bumpType
@ -80,7 +80,7 @@ func SetBumpType(bumpType string) {
} else if bumpType == "patch" {
Patch = true
} else {
logrus.Fatal("no version bump type specififed?")
log.Fatal("no version bump type specififed?")
}
}
@ -88,7 +88,11 @@ func SetBumpType(bumpType string) {
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
var path string
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return "", err
}
for _, service := range config.Services {
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {

View File

@ -6,22 +6,22 @@ import (
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
// ValidateRecipe ensures the recipe arg is valid.
func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First()
func ValidateRecipe(cmd *cli.Command) recipe.Recipe {
recipeName := cmd.Args().First()
if recipeName == "" && !NoInput {
var recipes []string
catl, err := recipe.ReadRecipeCatalogue(Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
knownRecipes := make(map[string]bool)
@ -31,7 +31,7 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
localRecipes, err := recipe.GetRecipesLocal()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, recipeLocal := range localRecipes {
@ -49,55 +49,60 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
Options: recipes,
}
if err := survey.AskOne(prompt, &recipeName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
ShowSubcommandHelpAndError(cmd, errors.New("no recipe name provided"))
}
chosenRecipe, err := recipe.Get(recipeName, Offline)
chosenRecipe := recipe.Get(recipeName)
err := chosenRecipe.EnsureExists()
if err != nil {
if c.Command.Name == "generate" {
log.Fatal(err)
}
_, err = chosenRecipe.GetComposeConfig(nil)
if err != nil {
if cmd.Name == "generate" {
if strings.Contains(err.Error(), "missing a compose") {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Warn(err)
log.Warn(err)
} else {
if strings.Contains(err.Error(), "template_driver is not allowed") {
logrus.Warnf("ensure %s recipe compose.* files include \"version: '3.8'\"", recipeName)
log.Warnf("ensure %s recipe compose.* files include \"version: '3.8'\"", recipeName)
}
logrus.Fatalf("unable to validate recipe: %s", err)
log.Fatalf("unable to validate recipe: %s", err)
}
}
logrus.Debugf("validated %s as recipe argument", recipeName)
log.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe
}
// ValidateApp ensures the app name arg is valid.
func ValidateApp(c *cli.Context) config.App {
appName := c.Args().First()
func ValidateApp(cmd *cli.Command) app.App {
appName := cmd.Args().First()
if appName == "" {
ShowSubcommandHelpAndError(c, errors.New("no app provided"))
ShowSubcommandHelpAndError(cmd, errors.New("no app provided"))
}
app, err := app.Get(appName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("validated %s as app argument", appName)
log.Debugf("validated %s as app argument", appName)
return app
}
// ValidateDomain ensures the domain name arg is valid.
func ValidateDomain(c *cli.Context) string {
domainName := c.Args().First()
func ValidateDomain(cmd *cli.Command) string {
domainName := cmd.Args().First()
if domainName == "" && !NoInput {
prompt := &survey.Input{
@ -105,24 +110,24 @@ func ValidateDomain(c *cli.Context) string {
Default: "example.com",
}
if err := survey.AskOne(prompt, &domainName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
if domainName == "" {
ShowSubcommandHelpAndError(c, errors.New("no domain provided"))
ShowSubcommandHelpAndError(cmd, errors.New("no domain provided"))
}
logrus.Debugf("validated %s as domain argument", domainName)
log.Debugf("validated %s as domain argument", domainName)
return domainName
}
// ValidateSubCmdFlags ensures flag order conforms to correct order
func ValidateSubCmdFlags(c *cli.Context) bool {
for argIdx, arg := range c.Args() {
func ValidateSubCmdFlags(cmd *cli.Command) bool {
for argIdx, arg := range cmd.Args().Slice() {
if !strings.HasPrefix(arg, "--") {
for _, flag := range c.Args()[argIdx:] {
for _, flag := range cmd.Args().Slice()[argIdx:] {
if strings.HasPrefix(flag, "--") {
return false
}
@ -133,12 +138,12 @@ func ValidateSubCmdFlags(c *cli.Context) bool {
}
// ValidateServer ensures the server name arg is valid.
func ValidateServer(c *cli.Context) string {
serverName := c.Args().First()
func ValidateServer(cmd *cli.Command) string {
serverName := cmd.Args().First()
serverNames, err := config.ReadServerNames()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if serverName == "" && !NoInput {
@ -147,7 +152,7 @@ func ValidateServer(c *cli.Context) string {
Options: serverNames,
}
if err := survey.AskOne(prompt, &serverName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -159,14 +164,14 @@ func ValidateServer(c *cli.Context) string {
}
if serverName == "" {
ShowSubcommandHelpAndError(c, errors.New("no server provided"))
ShowSubcommandHelpAndError(cmd, errors.New("no server provided"))
}
if !matched {
ShowSubcommandHelpAndError(c, errors.New("server doesn't exist?"))
ShowSubcommandHelpAndError(cmd, errors.New("server doesn't exist?"))
}
logrus.Debugf("validated %s as server argument", serverName)
log.Debugf("validated %s as server argument", serverName)
return serverName
}

View File

@ -1,38 +1,29 @@
package recipe
import (
"path"
"context"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
var recipeDiffCommand = cli.Command{
Name: "diff",
Usage: "Show unstaged changes in recipe config",
Description: "Due to limitations in our underlying Git dependency, this command requires /usr/bin/git.",
Aliases: []string{"d"},
ArgsUsage: "<recipe>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
Name: "diff",
Usage: "Show unstaged changes in recipe config",
Description: "This command requires /usr/bin/git.",
Aliases: []string{"d"},
UsageText: "abra recipe diff <recipe> [options]",
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
r := internal.ValidateRecipe(cmd)
if recipeName != "" {
internal.ValidateRecipe(c)
}
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
if err := gitPkg.DiffUnstaged(recipeDir); err != nil {
logrus.Fatal(err)
if err := gitPkg.DiffUnstaged(r.Dir); err != nil {
log.Fatal(err)
}
return nil

View File

@ -1,46 +1,46 @@
package recipe
import (
"context"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var recipeFetchCommand = cli.Command{
Name: "fetch",
Usage: "Fetch recipe(s)",
Aliases: []string{"f"},
ArgsUsage: "[<recipe>]",
Description: "Retrieves all recipes if no <recipe> argument is passed",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
Name: "fetch",
Usage: "Fetch recipe(s)",
Aliases: []string{"f"},
UsageText: "abra recipe fetch [<recipe>] [options]",
Description: "Retrieves all recipes if no <recipe> argument is passed",
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipeName := cmd.Args().First()
r := recipe.Get(recipeName)
if recipeName != "" {
internal.ValidateRecipe(c)
if err := recipe.Ensure(recipeName); err != nil {
logrus.Fatal(err)
internal.ValidateRecipe(cmd)
if err := r.Ensure(false, false); err != nil {
log.Fatal(err)
}
return nil
}
catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
catlBar := formatter.CreateProgressbar(len(catalogue), "fetching latest recipes...")
for recipeName := range catalogue {
if err := recipe.Ensure(recipeName); err != nil {
logrus.Error(err)
r := recipe.Get(recipeName)
if err := r.Ensure(false, false); err != nil {
log.Error(err)
}
catlBar.Add(1)
}

View File

@ -1,63 +1,59 @@
package recipe
import (
"context"
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/lint"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
var recipeLintCommand = cli.Command{
Name: "lint",
Usage: "Lint a recipe",
Aliases: []string{"l"},
ArgsUsage: "<recipe>",
UsageText: "abra recipe lint <recipe> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OnlyErrorFlag,
internal.OfflineFlag,
internal.NoInputFlag,
internal.ChaosFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipe := internal.ValidateRecipe(cmd)
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
logrus.Fatal(err)
if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if !internal.Chaos {
if err := recipePkg.EnsureIsClean(recipe.Name); err != nil {
logrus.Fatal(err)
}
if !internal.Offline {
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
}
}
if err := recipePkg.EnsureLatest(recipe.Name); err != nil {
logrus.Fatal(err)
}
headers := []string{
"ref",
"rule",
"severity",
"satisfied",
"skipped",
"resolve",
}
tableCol := []string{"ref", "rule", "severity", "satisfied", "skipped", "resolve"}
table := formatter.CreateTable(tableCol)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
hasError := false
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
var rows [][]string
var warnMessages []string
for level := range lint.LintRules {
for _, rule := range lint.LintRules[level] {
if internal.OnlyErrors && rule.Level != "error" {
logrus.Debugf("skipping %s, does not have level \"error\"", rule.Ref)
log.Debugf("skipping %s, does not have level \"error\"", rule.Ref)
continue
}
@ -75,7 +71,7 @@ var recipeLintCommand = cli.Command{
if !skipped {
ok, err := rule.Function(recipe)
if err != nil {
logrus.Warn(err)
warnMessages = append(warnMessages, err.Error())
}
if !ok && rule.Level == "error" {
@ -95,26 +91,30 @@ var recipeLintCommand = cli.Command{
}
}
table.Append([]string{
row := []string{
rule.Ref,
rule.Description,
rule.Level,
satisfiedOutput,
skippedOutput,
rule.HowToResolve,
})
}
bar.Add(1)
rows = append(rows, row)
table.Row(row...)
}
}
if table.NumLines() > 0 {
fmt.Println()
table.Render()
}
if len(rows) > 0 {
fmt.Println(table)
if hasError {
logrus.Warn("watch out, some critical errors are present in your recipe config")
for _, warnMsg := range warnMessages {
log.Warn(warnMsg)
}
if hasError {
log.Warnf("critical errors present in %s config", recipe.Name)
}
}
return nil

View File

@ -1,6 +1,7 @@
package recipe
import (
"context"
"fmt"
"sort"
"strconv"
@ -8,45 +9,61 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var pattern string
var patternFlag = &cli.StringFlag{
Name: "pattern, p",
Name: "pattern",
Aliases: []string{"p"},
Value: "",
Usage: "Simple string to filter recipes",
Destination: &pattern,
}
var recipeListCommand = cli.Command{
Name: "list",
Usage: "List available recipes",
Aliases: []string{"ls"},
Name: "list",
Usage: "List recipes",
UsageText: "abra recipe list [options]",
Aliases: []string{"ls"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.MachineReadableFlag,
patternFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
catl, err := recipe.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err.Error())
log.Fatal(err.Error())
}
recipes := catl.Flatten()
sort.Sort(recipe.ByRecipeName(recipes))
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"}
table := formatter.CreateTable(tableCol)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
len := 0
headers := []string{
"name",
"category",
"status",
"healthcheck",
"backups",
"email",
"tests",
"SSO",
}
table.Headers(headers...)
var rows [][]string
for _, recipe := range recipes {
tableRow := []string{
row := []string{
recipe.Name,
recipe.Category,
strconv.Itoa(recipe.Features.Status),
@ -59,23 +76,27 @@ var recipeListCommand = cli.Command{
if pattern != "" {
if strings.Contains(recipe.Name, pattern) {
table.Append(tableRow)
len++
table.Row(row...)
rows = append(rows, row)
}
} else {
table.Append(tableRow)
len++
table.Row(row...)
rows = append(rows, row)
}
}
if table.NumLines() > 0 {
if len(rows) > 0 {
if internal.MachineReadable {
table.SetCaption(false, "")
table.JSONRender()
} else {
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
table.Render()
out, err := formatter.ToJSON(headers, rows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
}
fmt.Println(table)
log.Infof("total recipes: %v", len(rows))
}
return nil

View File

@ -2,9 +2,9 @@ package recipe
import (
"bytes"
"context"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"text/template"
@ -12,8 +12,9 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/git"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/urfave/cli/v3"
)
// recipeMetadata is the recipe metadata for the README.md
@ -34,91 +35,66 @@ var recipeNewCommand = cli.Command{
Name: "new",
Aliases: []string{"n"},
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
internal.GitNameFlag,
internal.GitEmailFlag,
},
Before: internal.SubCommandBefore,
Usage: "Create a new recipe",
ArgsUsage: "<recipe>",
Description: `
Create a new recipe.
UsageText: "abra recipe new <recipe> [options]",
Description: `Create a new recipe.
Abra uses the built-in example repository which is available here:
https://git.coopcloud.tech/coop-cloud/example
Files within the example repository make use of the Golang templating system
which Abra uses to inject values into the generated recipe folder (e.g. name of
recipe and domain in the sample environment config).
`,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
https://git.coopcloud.tech/coop-cloud/example`,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipeName := cmd.Args().First()
r := recipe.Get(recipeName)
if recipeName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided"))
internal.ShowSubcommandHelpAndError(cmd, errors.New("no recipe name provided"))
}
directory := path.Join(config.RECIPES_DIR, recipeName)
if _, err := os.Stat(directory); !os.IsNotExist(err) {
logrus.Fatalf("%s recipe directory already exists?", directory)
if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
log.Fatalf("%s recipe directory already exists?", r.Dir)
}
url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL)
if err := git.Clone(directory, url); err != nil {
logrus.Fatal(err)
if err := git.Clone(r.Dir, url); err != nil {
log.Fatal(err)
}
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git")
gitRepo := path.Join(r.Dir, ".git")
if err := os.RemoveAll(gitRepo); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Debugf("removed example git repo in %s", gitRepo)
log.Debugf("removed example git repo in %s", gitRepo)
meta := newRecipeMeta(recipeName)
toParse := []string{
path.Join(config.RECIPES_DIR, recipeName, "README.md"),
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"),
}
for _, path := range toParse {
for _, path := range []string{r.ReadmePath, r.SampleEnvPath} {
tpl, err := template.ParseFiles(path)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var templated bytes.Buffer
if err := tpl.Execute(&templated, meta); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
logrus.Fatal(err)
if err := os.WriteFile(path, templated.Bytes(), 0o644); err != nil {
log.Fatal(err)
}
}
newGitRepo := path.Join(config.RECIPES_DIR, recipeName)
if err := git.Init(newGitRepo, true); err != nil {
logrus.Fatal(err)
if err := git.Init(r.Dir, true, internal.GitName, internal.GitEmail); err != nil {
log.Fatal(err)
}
fmt.Print(fmt.Sprintf(`
Your new %s recipe has been created in %s.
In order to share your recipe, you can upload it the git repository to:
https://git.coopcloud.tech/coop-cloud/%s
If you're not sure how to do that, come chat with us:
https://docs.coopcloud.tech/intro/contact
See "abra recipe -h" for additional recipe maintainer commands.
Happy Hacking!
`, recipeName, path.Join(config.RECIPES_DIR, recipeName), recipeName))
log.Infof("new recipe '%s' created: %s", recipeName, path.Join(r.Dir))
log.Info("happy hacking 🎉")
return nil
},

View File

@ -1,7 +1,7 @@
package recipe
import (
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
// RecipeCommand defines all recipe related sub-commands.
@ -9,28 +9,26 @@ var RecipeCommand = cli.Command{
Name: "recipe",
Aliases: []string{"r"},
Usage: "Manage recipes",
ArgsUsage: "<recipe>",
Description: `
A recipe is a blueprint for an app. It is a bunch of config files which
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
Cloud community and you can use Abra to read them, deploy them and create apps
for you.
UsageText: "abra recipe [command] [arguments] [options]",
Description: `A recipe is a blueprint for an app.
It is a bunch of config files which describe how to deploy and maintain an app.
Recipes are maintained by the Co-op Cloud community and you can use Abra to
read them, deploy them and create apps for you.
Anyone who uses a recipe can become a maintainer. Maintainers typically make
sure the recipe is in good working order and the config upgraded in a timely
manner. Abra supports convenient automation for recipe maintainenace, see the
"abra recipe upgrade", "abra recipe sync" and "abra recipe release" commands.
`,
Subcommands: []cli.Command{
recipeFetchCommand,
recipeLintCommand,
recipeListCommand,
recipeNewCommand,
recipeReleaseCommand,
recipeSyncCommand,
recipeUpgradeCommand,
recipeVersionCommand,
recipeResetCommand,
recipeDiffCommand,
manner.`,
Commands: []*cli.Command{
&recipeFetchCommand,
&recipeLintCommand,
&recipeListCommand,
&recipeNewCommand,
&recipeReleaseCommand,
&recipeSyncCommand,
&recipeUpgradeCommand,
&recipeVersionCommand,
&recipeResetCommand,
&recipeDiffCommand,
},
}

View File

@ -1,6 +1,7 @@
package recipe
import (
"context"
"errors"
"fmt"
"os"
@ -10,27 +11,26 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var recipeReleaseCommand = cli.Command{
Name: "release",
Aliases: []string{"rl"},
Usage: "Release a new recipe version",
ArgsUsage: "<recipe> [<version>]",
Description: `
Create a new version of a recipe. These versions are then published on the
Co-op Cloud recipe catalogue. These versions take the following form:
UsageText: "abra recipe release <recipe> [<version>] [options]",
Description: `Create a new version of a recipe.
These versions are then published on the Co-op Cloud recipe catalogue. These
versions take the following form:
a.b.c+x.y.z
@ -46,93 +46,90 @@ major and therefore require intervention while doing the upgrade work.
Publish your new release to git.coopcloud.tech with "-p/--publish". This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account.
`,
your SSH keys configured on your account.`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
internal.PublishFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipe := internal.ValidateRecipe(cmd)
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
if mainAppVersion == "" {
logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
log.Fatalf("main app service version for %s is empty?", recipe.Name)
}
tagString := c.Args().Get(1)
tagString := cmd.Args().Get(1)
if tagString != "" {
if _, err := tagcmp.Parse(tagString); err != nil {
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
log.Fatalf("cannot parse %s, invalid tag specified?", tagString)
}
}
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
logrus.Fatal("cannot specify tag and bump type at the same time")
log.Fatal("cannot specify tag and bump type at the same time")
}
if tagString != "" {
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
tags, err := recipe.Tags()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
var err error
tagString, err = getLabelVersion(recipe, false)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
isClean, err := gitPkg.IsClean(recipe.Dir())
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isClean {
logrus.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipe.Dir()); err != nil {
logrus.Fatal(err)
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
}
if len(tags) > 0 {
logrus.Warnf("previous git tags detected, assuming this is a new semver release")
log.Warnf("previous git tags detected, assuming this is a new semver release")
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
} else {
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
log.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
logrus.Fatal(cleanUpErr)
if cleanUpErr := cleanUpTag(recipe, tagString); err != nil {
log.Fatal(cleanUpErr)
}
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -144,8 +141,12 @@ your SSH keys configured on your account.
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
services := make(map[string]string)
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return nil, err
}
missingTag := false
for _, service := range recipe.Config.Services {
for _, service := range config.Services {
if service.Image == "" {
continue
}
@ -184,8 +185,7 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
var err error
directory := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(directory)
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return err
}
@ -210,19 +210,19 @@ func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string
}
if err := addReleaseNotes(recipe, tagString); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := tagRelease(tagString, repo); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := pushRelease(recipe, tagString); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil
@ -246,8 +246,7 @@ func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
// addReleaseNotes checks if the release/next release note exists and moves the
// file to release/<tag>.
func addReleaseNotes(recipe recipe.Recipe, tag string) error {
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
tagReleaseNotePath := path.Join(repoPath, "release", tag)
tagReleaseNotePath := path.Join(recipe.Dir, "release", tag)
if _, err := os.Stat(tagReleaseNotePath); err == nil {
// Release note for current tag already exist exists.
return nil
@ -255,11 +254,11 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
return err
}
nextReleaseNotePath := path.Join(repoPath, "release", "next")
nextReleaseNotePath := path.Join(recipe.Dir, "release", "next")
if _, err := os.Stat(nextReleaseNotePath); err == nil {
// release/next note exists. Move it to release/<tag>
if internal.Dry {
logrus.Debugf("dry run: move release note from 'next' to %s", tag)
log.Debugf("dry run: move release note from 'next' to %s", tag)
return nil
}
if !internal.NoInput {
@ -278,11 +277,11 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
if err != nil {
return err
}
err = gitPkg.Add(repoPath, path.Join("release", "next"), internal.Dry)
err = gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry)
if err != nil {
return err
}
err = gitPkg.Add(repoPath, path.Join("release", tag), internal.Dry)
err = gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry)
if err != nil {
return err
}
@ -311,7 +310,7 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
if err != nil {
return err
}
err = gitPkg.Add(repoPath, path.Join("release", tag), internal.Dry)
err = gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry)
if err != nil {
return err
}
@ -321,24 +320,23 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
func commitRelease(recipe recipe.Recipe, tag string) error {
if internal.Dry {
logrus.Debugf("dry run: no changes committed")
log.Debugf("dry run: no changes committed")
return nil
}
isClean, err := gitPkg.IsClean(recipe.Dir())
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
return err
}
if isClean {
if !internal.Dry {
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir())
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir)
}
}
msg := fmt.Sprintf("chore: publish %s release", tag)
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
if err := gitPkg.Commit(repoPath, msg, internal.Dry); err != nil {
if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
return err
}
@ -347,7 +345,7 @@ func commitRelease(recipe recipe.Recipe, tag string) error {
func tagRelease(tagString string, repo *git.Repository) error {
if internal.Dry {
logrus.Debugf("dry run: no git tag created (%s)", tagString)
log.Debugf("dry run: no git tag created (%s)", tagString)
return nil
}
@ -367,14 +365,14 @@ func tagRelease(tagString string, repo *git.Repository) error {
}
hash := formatter.SmallSHA(head.Hash().String())
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
log.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
return nil
}
func pushRelease(recipe recipe.Recipe, tagString string) error {
if internal.Dry {
logrus.Info("dry run: no changes published")
log.Info("dry run: no changes published")
return nil
}
@ -392,18 +390,17 @@ func pushRelease(recipe recipe.Recipe, tagString string) error {
if err := recipe.Push(internal.Dry); err != nil {
return err
}
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
logrus.Infof("new release published: %s", url)
url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
log.Infof("new release published: %s", url)
} else {
logrus.Info("no -p/--publish passed, not publishing")
log.Info("no -p/--publish passed, not publishing")
}
return nil
}
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
directory := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(directory)
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return err
}
@ -468,7 +465,7 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
}
if lastGitTag.String() == tagString {
logrus.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
log.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
}
if !internal.NoInput {
@ -478,37 +475,36 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
var ok bool
if err := survey.AskOne(prompt, &ok); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !ok {
logrus.Fatal("exiting as requested")
log.Fatal("exiting as requested")
}
}
if err := addReleaseNotes(recipe, tagString); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to commit changes: %s", err.Error())
log.Fatalf("failed to commit changes: %s", err.Error())
}
if err := tagRelease(tagString, repo); err != nil {
logrus.Fatalf("failed to tag release: %s", err.Error())
log.Fatalf("failed to tag release: %s", err.Error())
}
if err := pushRelease(recipe, tagString); err != nil {
logrus.Fatalf("failed to publish new release: %s", err.Error())
log.Fatalf("failed to publish new release: %s", err.Error())
}
return nil
}
// cleanUpTag removes a freshly created tag
func cleanUpTag(tag, recipeName string) error {
directory := path.Join(config.RECIPES_DIR, recipeName)
repo, err := git.PlainOpen(directory)
func cleanUpTag(recipe recipe.Recipe, tag string) error {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return err
}
@ -519,22 +515,22 @@ func cleanUpTag(tag, recipeName string) error {
}
}
logrus.Debugf("removed freshly created tag %s", tag)
log.Debugf("removed freshly created tag %s", tag)
return nil
}
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
initTag, err := recipe.GetVersionLabelLocal()
if err != nil {
return "", err
}
if initTag == "" {
logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
log.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
}
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
log.Warnf("discovered %s as currently synced recipe label", initTag)
if prompt && !internal.NoInput {
var response bool

View File

@ -1,54 +1,51 @@
package recipe
import (
"path"
"context"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var recipeResetCommand = cli.Command{
Name: "reset",
Usage: "Remove all unstaged changes from recipe config",
Description: "WARNING, this will delete your changes. Be Careful.",
Aliases: []string{"rs"},
ArgsUsage: "<recipe>",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
Name: "reset",
Usage: "Remove all unstaged changes from recipe config",
Description: "WARNING: this will delete your changes. Be Careful.",
Aliases: []string{"rs"},
UsageText: "abra recipe reset <recipe> [options]",
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipeName := cmd.Args().First()
r := recipe.Get(recipeName)
if recipeName != "" {
internal.ValidateRecipe(c)
internal.ValidateRecipe(cmd)
}
repoPath := path.Join(config.RECIPES_DIR, recipeName)
repo, err := git.PlainOpen(repoPath)
repo, err := git.PlainOpen(r.Dir)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
ref, err := repo.Head()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
worktree, err := repo.Worktree()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
opts := &git.ResetOptions{Commit: ref.Hash(), Mode: git.HardReset}
if err := worktree.Reset(opts); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil

View File

@ -1,72 +1,69 @@
package recipe
import (
"context"
"fmt"
"path"
"strconv"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var recipeSyncCommand = cli.Command{
Name: "sync",
Aliases: []string{"s"},
Usage: "Sync recipe version label",
ArgsUsage: "<recipe> [<version>]",
UsageText: "abra recipe lint <recipe> [<version>] [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
},
Before: internal.SubCommandBefore,
Description: `
Generate labels for the main recipe service (i.e. by convention, the service
named "app") which corresponds to the following format:
Description: `Generate labels for the main recipe service.
By convention, the service named "app" using the following format:
coop-cloud.${STACK_NAME}.version=<version>
Where <version> can be specifed on the command-line or Abra can attempt to
auto-generate it for you. The <recipe> configuration will be updated on the
local file system.
`,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
local file system.`,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipe := internal.ValidateRecipe(cmd)
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
imagesTmp, err := getImageVersions(recipe)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
tags, err := recipe.Tags()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
nextTag := c.Args().Get(1)
nextTag := cmd.Args().Get(1)
if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no git tags found for %s", recipe.Name)
log.Warnf("no git tags found for %s", recipe.Name)
if internal.NoInput {
logrus.Fatalf("unable to continue, input required for initial version")
log.Fatalf("unable to continue, input required for initial version")
}
fmt.Println(fmt.Sprintf(`
The following options are two types of initial semantic version that you can
@ -93,7 +90,7 @@ likely to change.
}
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
@ -102,27 +99,26 @@ likely to change.
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
latestRelease := tags[len(tags)-1]
if err := internal.PromptBumpType("", latestRelease); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
if nextTag == "" {
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir)
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var lastGitTag tagcmp.Tag
iter, err := repo.Tags()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash())
if err != nil {
logrus.Fatal("Tag at commit ", ref.Hash(), " is unannotated or otherwise broken. Please fix it.")
log.Fatal("Tag at commit ", ref.Hash(), " is unannotated or otherwise broken. Please fix it.")
return err
}
@ -139,7 +135,7 @@ likely to change.
return nil
}); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
// bumpType is used to decide what part of the tag should be incremented
@ -147,7 +143,7 @@ likely to change.
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one version flag: --major, --minor or --patch")
log.Fatal("you can only use one version flag: --major, --minor or --patch")
}
}
@ -156,14 +152,14 @@ likely to change.
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
newTag.Patch = "0"
@ -171,7 +167,7 @@ likely to change.
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
newTag.Patch = "0"
@ -181,32 +177,32 @@ likely to change.
}
newTag.Metadata = mainAppVersion
logrus.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
log.Debugf("choosing %s as new version for %s", newTag.String(), recipe.Name)
nextTag = newTag.String()
}
if _, err := tagcmp.Parse(nextTag); err != nil {
logrus.Fatalf("invalid version %s specified", nextTag)
log.Fatalf("invalid version %s specified", nextTag)
}
mainService := "app"
label := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", nextTag)
if !internal.Dry {
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
} else {
logrus.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
log.Infof("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name)
}
isClean, err := gitPkg.IsClean(recipe.Dir())
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isClean {
logrus.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipe.Dir()); err != nil {
logrus.Fatal(err)
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
}

View File

@ -2,6 +2,7 @@ package recipe
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
@ -12,15 +13,14 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/distribution/reference"
"github.com/urfave/cli/v3"
)
type imgPin struct {
@ -28,8 +28,8 @@ type imgPin struct {
version tagcmp.Tag
}
// anUpgrade represents a single service upgrade (as within a recipe), and the list of tags that it can be upgraded to,
// for serialization purposes.
// anUpgrade represents a single service upgrade (as within a recipe), and the
// list of tags that it can be upgraded to, for serialization purposes.
type anUpgrade struct {
Service string `json:"service"`
Image string `json:"image"`
@ -38,13 +38,13 @@ type anUpgrade struct {
}
var recipeUpgradeCommand = cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade recipe image tags",
Description: `
Parse all image tags within the given <recipe> configuration and prompt with
more recent tags to upgrade to. It will update the relevant compose file tags
on the local file system.
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade recipe image tags",
UsageText: "abra recipe upgrade [<recipe>] [options]",
Description: `Upgrade a given <recipe> configuration.
It will update the relevant compose file tags on the local file system.
Some image tags cannot be parsed because they do not follow some sort of
semver-like convention. In this case, all possible tags will be listed and it
@ -54,46 +54,29 @@ The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe upgrade
`,
ArgsUsage: "<recipe>",
You may invoke this command in "wizard" mode and be prompted for input.`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.PatchFlag,
internal.MinorFlag,
internal.MajorFlag,
internal.MachineReadableFlag,
internal.AllTagsFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
recipe := internal.ValidateRecipe(cmd)
if err := recipePkg.EnsureIsClean(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
logrus.Fatal(err)
}
if err := recipePkg.EnsureLatest(recipe.Name); err != nil {
logrus.Fatal(err)
if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
log.Fatal("you can only use one of: --major, --minor, --patch.")
}
}
@ -106,26 +89,25 @@ You may invoke this command in "wizard" mode and be prompted for input:
// check for versions file and load pinned versions
versionsPresent := false
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
versionsPath := path.Join(recipeDir, "versions")
var servicePins = make(map[string]imgPin)
versionsPath := path.Join(recipe.Dir, "versions")
servicePins := make(map[string]imgPin)
if _, err := os.Stat(versionsPath); err == nil {
logrus.Debugf("found versions file for %s", recipe.Name)
log.Debugf("found versions file for %s", recipe.Name)
file, err := os.Open(versionsPath)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
splitLine := strings.Split(line, " ")
if splitLine[0] != "pin" || len(splitLine) != 3 {
logrus.Fatalf("malformed version pin specification: %s", line)
log.Fatalf("malformed version pin specification: %s", line)
}
pinSlice := strings.Split(splitLine[2], ":")
pinTag, err := tagcmp.Parse(pinSlice[1])
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
pin := imgPin{
image: pinSlice[0],
@ -134,45 +116,50 @@ You may invoke this command in "wizard" mode and be prompted for input:
servicePins[splitLine[1]] = pin
}
if err := scanner.Err(); err != nil {
logrus.Error(err)
log.Error(err)
}
versionsPresent = true
} else {
logrus.Debugf("did not find versions file for %s", recipe.Name)
log.Debugf("did not find versions file for %s", recipe.Name)
}
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
log.Fatal(err)
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
regVersions, err := client.GetRegistryTags(img)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
image := reference.Path(img)
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
log.Debugf("retrieved %s from remote registry for %s", regVersions, image)
image = formatter.StripTagMeta(image)
switch img.(type) {
case reference.NamedTagged:
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
log.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
}
default:
logrus.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
log.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
continue
}
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
if err != nil {
logrus.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name)
log.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name)
continue
}
logrus.Debugf("parsed %s for %s", tag, service.Name)
log.Debugf("parsed %s for %s", tag, service.Name)
var compatible []tagcmp.Tag
for _, regVersion := range regVersions {
@ -186,18 +173,18 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
logrus.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)
log.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name)
sort.Sort(tagcmp.ByTagDesc(compatible))
if len(compatible) == 0 && !internal.AllTags {
logrus.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
log.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag))
continue // skip on to the next tag and don't update any compose files
}
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
compatibleStrings := []string{"skip"}
@ -213,7 +200,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
log.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)
var upgradeTag string
_, ok := servicePins[service.Name]
@ -230,13 +217,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if contains {
logrus.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
log.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
} else {
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
log.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
continue
}
} else {
logrus.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
log.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
continue
}
} else {
@ -253,7 +240,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if upgradeTag == "" {
logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image)
log.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image)
continue
}
} else {
@ -261,7 +248,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || internal.AllTags {
tag := img.(reference.NamedTagged).Tag()
if !internal.AllTags {
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
log.Warn(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
}
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
compatibleStrings = []string{"skip"}
@ -299,7 +286,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
Options: compatibleStrings,
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
}
@ -307,14 +294,14 @@ You may invoke this command in "wizard" mode and be prompted for input:
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if ok {
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
log.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
}
} else {
if !internal.NoInput {
logrus.Warnf("not upgrading %s, skipping as requested", image)
log.Warnf("not upgrading %s, skipping as requested", image)
}
}
}
@ -323,7 +310,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
if internal.MachineReadable {
jsonstring, err := json.Marshal(upgradeList)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
fmt.Println(string(jsonstring))
@ -332,21 +319,21 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
for _, upgrade := range upgradeList {
logrus.Infof("can upgrade service: %s, image: %s, tag: %s ::\n", upgrade.Service, upgrade.Image, upgrade.Tag)
log.Infof("can upgrade service: %s, image: %s, tag: %s ::", upgrade.Service, upgrade.Image, upgrade.Tag)
for _, utag := range upgrade.UpgradeTags {
logrus.Infof(" %s\n", utag)
log.Infof(" %s", utag)
}
}
}
isClean, err := gitPkg.IsClean(recipeDir)
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !isClean {
logrus.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipeDir); err != nil {
logrus.Fatal(err)
log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
}

View File

@ -1,16 +1,16 @@
package recipe
import (
"context"
"fmt"
"sort"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/olekukonko/tablewriter"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
func sortServiceByName(versions [][]string) func(i, j int) bool {
@ -24,62 +24,89 @@ func sortServiceByName(versions [][]string) func(i, j int) bool {
}
var recipeVersionCommand = cli.Command{
Name: "versions",
Aliases: []string{"v"},
Usage: "List recipe versions",
ArgsUsage: "<recipe>",
Description: "Versions are read from the recipe catalogue.",
Name: "versions",
Aliases: []string{"v"},
Usage: "List recipe versions",
UsageText: "abra recipe version <recipe> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.OfflineFlag,
internal.NoInputFlag,
internal.MachineReadableFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.RecipeNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
var warnMessages []string
recipe := internal.ValidateRecipe(cmd)
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
recipeMeta, ok := catl[recipe.Name]
if !ok {
logrus.Fatalf("%s is not published on the catalogue?", recipe.Name)
warnMessages = append(warnMessages, "retrieved versions from local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions()
if err != nil {
warnMessages = append(warnMessages, err.Error())
}
recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions}
}
if len(recipeMeta.Versions) == 0 {
logrus.Fatalf("%s has no catalogue published versions?", recipe.Name)
log.Fatalf("%s has no published versions?", recipe.Name)
}
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
tableCols := []string{"version", "service", "image", "tag"}
table := formatter.CreateTable(tableCols)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers("SERVICE", "NAME", "TAG")
for version, meta := range recipeMeta.Versions[i] {
var versions [][]string
var allRows [][]string
var rows [][]string
for service, serviceMeta := range meta {
versions = append(versions, []string{version, service, serviceMeta.Image, serviceMeta.Tag})
rows = append(rows, []string{service, serviceMeta.Image, serviceMeta.Tag})
allRows = append(allRows, []string{version, service, serviceMeta.Image, serviceMeta.Tag})
}
sort.Slice(versions, sortServiceByName(versions))
sort.Slice(rows, sortServiceByName(rows))
for _, version := range versions {
table.Append(version)
table.Rows(rows...)
if !internal.MachineReadable {
fmt.Println(table)
log.Infof("VERSION: %s", version)
fmt.Println()
continue
}
if internal.MachineReadable {
table.JSONRender()
} else {
table.SetAutoMergeCellsByColumnIndex([]int{0})
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.Render()
fmt.Println()
sort.Slice(allRows, sortServiceByName(allRows))
headers := []string{"VERSION", "SERVICE", "NAME", "TAG"}
out, err := formatter.ToJSON(headers, allRows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
continue
}
}
}
if !internal.MachineReadable {
for _, warnMsg := range warnMessages {
log.Warn(warnMsg)
}
}
return nil
},
}

View File

@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"os"
"path/filepath"
@ -10,42 +11,43 @@ import (
"coopcloud.tech/abra/pkg/config"
contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/server"
sshPkg "coopcloud.tech/abra/pkg/ssh"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var local bool
var localFlag = &cli.BoolFlag{
Name: "local, l",
Name: "local",
Aliases: []string{"l"},
Usage: "Use local server",
Destination: &local,
}
func cleanUp(domainName string) {
if domainName != "default" {
logrus.Infof("cleaning up context for %s", domainName)
if err := client.DeleteContext(domainName); err != nil {
logrus.Fatal(err)
// cleanUp cleans up the partially created context/client details for a failed
// "server add" attempt.
func cleanUp(name string) {
if name != "default" {
log.Debugf("serverAdd: cleanUp: cleaning up context for %s", name)
if err := client.DeleteContext(name); err != nil {
log.Fatal(err)
}
}
logrus.Infof("attempting to clean up server directory for %s", domainName)
serverDir := filepath.Join(config.SERVERS_DIR, domainName)
serverDir := filepath.Join(config.SERVERS_DIR, name)
files, err := config.GetAllFilesInDirectory(serverDir)
if err != nil {
logrus.Fatalf("unable to list files in %s: %s", serverDir, err)
log.Fatalf("serverAdd: cleanUp: unable to list files in %s: %s", serverDir, err)
}
if len(files) > 0 {
logrus.Warnf("aborting clean up of %s because it is not empty", serverDir)
log.Debugf("serverAdd: cleanUp: %s is not empty, aborting cleanup", serverDir)
return
}
if err := os.RemoveAll(serverDir); err != nil {
logrus.Fatal(err)
log.Fatalf("serverAdd: cleanUp: failed to remove %s: %s", serverDir, err)
}
}
@ -53,128 +55,143 @@ func cleanUp(domainName string) {
// Docker manages SSH connection details. These are stored to disk in
// ~/.docker. Abra can manage this completely for the user, so it's an
// implementation detail.
func newContext(c *cli.Context, domainName, username, port string) error {
func newContext(name string) (bool, error) {
store := contextPkg.NewDefaultDockerContextStore()
contexts, err := store.Store.List()
if err != nil {
return err
return false, err
}
for _, context := range contexts {
if context.Name == domainName {
logrus.Debugf("context for %s already exists", domainName)
return nil
if context.Name == name {
log.Debugf("context for %s already exists", name)
return false, nil
}
}
logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port)
log.Debugf("creating context with domain %s", name)
if err := client.CreateContext(domainName, username, port); err != nil {
return err
if err := client.CreateContext(name); err != nil {
return false, nil
}
return nil
return true, nil
}
// createServerDir creates the ~/.abra/servers/... directory for a new server.
func createServerDir(domainName string) error {
if err := server.CreateServerDir(domainName); err != nil {
func createServerDir(name string) (bool, error) {
if err := server.CreateServerDir(name); err != nil {
if !os.IsExist(err) {
return err
return false, err
}
logrus.Debugf("server dir for %s already created", domainName)
log.Debugf("server dir for %s already created", name)
return false, nil
}
return nil
return true, nil
}
var serverAddCommand = cli.Command{
Name: "add",
Aliases: []string{"a"},
Usage: "Add a server to your configuration",
Description: `
Add a new server to your configuration so that it can be managed by Abra.
Name: "add",
Aliases: []string{"a"},
Usage: "Add a new server",
UsageText: "abra server add <domain> [options]",
Description: `Add a new server to your configuration so that it can be managed by Abra.
Abra uses the SSH command-line to discover connection details for your server.
It is advised to configure an entry per-host in your ~/.ssh/config for each
server. For example:
Abra relies on the standard SSH command-line and ~/.ssh/config for client
connection details. You must configure an entry per-host in your ~/.ssh/config
for each server:
Host example.com
Hostname example.com
User exampleUser
Port 12345
IdentityFile ~/.ssh/example@somewhere
Abra can then load SSH connection details from this configuratiion with:
abra server add example.com
Host example.com example
Hostname example.com
User exampleUser
Port 12345
IdentityFile ~/.ssh/example@somewhere
If "--local" is passed, then Abra assumes that the current local server is
intended as the target server. This is useful when you want to have your entire
Co-op Cloud config located on the server itself, and not on your local
developer machine.
`,
developer machine. The domain is then set to "default".
You can also pass "--no-domain-checks/-D" flag to use any arbitrary name
instead of a real domain. The host will be resolved with the "Hostname" entry
of your ~/.ssh/config. Checks for a valid online domain will be skipped.`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.NoDomainChecksFlag,
localFlag,
},
Before: internal.SubCommandBefore,
ArgsUsage: "<domain>",
Action: func(c *cli.Context) error {
if len(c.Args()) > 0 && local || !internal.ValidateSubCmdFlags(c) {
err := errors.New("cannot use <domain> and --local together")
internal.ShowSubcommandHelpAndError(c, err)
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() > 0 && local || !internal.ValidateSubCmdFlags(cmd) {
err := errors.New("cannot use <name> and --local together")
internal.ShowSubcommandHelpAndError(cmd, err)
}
var domainName string
var name string
if local {
domainName = "default"
name = "default"
} else {
domainName = internal.ValidateDomain(c)
name = internal.ValidateDomain(cmd)
}
// NOTE(d1): reasonable 5 second timeout for connections which can't
// succeed. The connection is attempted twice, so this results in 10
// seconds.
timeout := client.WithTimeout(5)
if local {
if err := createServerDir(domainName); err != nil {
logrus.Fatal(err)
created, err := createServerDir(name)
if err != nil {
log.Fatal(err)
}
logrus.Infof("attempting to create client for %s", domainName)
if _, err := client.New(domainName); err != nil {
cleanUp(domainName)
logrus.Fatal(err)
log.Debugf("attempting to create client for %s", name)
if _, err := client.New(name, timeout); err != nil {
cleanUp(name)
log.Fatal(err)
}
logrus.Info("local server added")
if created {
log.Info("local server successfully added")
} else {
log.Warn("local server already exists")
}
return nil
}
if _, err := dns.EnsureIPv4(domainName); err != nil {
logrus.Fatal(err)
if !internal.NoDomainChecks {
if _, err := dns.EnsureIPv4(name); err != nil {
log.Fatal(err)
}
}
if err := createServerDir(domainName); err != nil {
logrus.Fatal(err)
}
hostConfig, err := sshPkg.GetHostConfig(domainName)
_, err := createServerDir(name)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := newContext(c, domainName, hostConfig.User, hostConfig.Port); err != nil {
logrus.Fatal(err)
created, err := newContext(name)
if err != nil {
cleanUp(name)
log.Fatal(err)
}
logrus.Infof("attempting to create client for %s", domainName)
if _, err := client.New(domainName); err != nil {
cleanUp(domainName)
logrus.Debugf("failed to construct client for %s, saw %s", domainName, err.Error())
logrus.Fatal(sshPkg.Fatal(domainName, err))
log.Debugf("attempting to create client for %s", name)
if _, err := client.New(name, timeout); err != nil {
cleanUp(name)
log.Fatal(sshPkg.Fatal(name, err))
}
logrus.Infof("%s added", domainName)
if created {
log.Infof("%s successfully added", name)
} else {
log.Warnf("%s already exists", name)
}
return nil
},

View File

@ -1,110 +1,97 @@
package server
import (
"context"
"fmt"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/context"
contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"github.com/docker/cli/cli/connhelper/ssh"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var problemsFilter bool
var problemsFilterFlag = &cli.BoolFlag{
Name: "problems, p",
Usage: "Show only servers with potential connection problems",
Destination: &problemsFilter,
}
var serverListCommand = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List managed servers",
Name: "list",
Aliases: []string{"ls"},
Usage: "List managed servers",
UsageText: "abra server list [options]",
Flags: []cli.Flag{
problemsFilterFlag,
internal.DebugFlag,
internal.MachineReadableFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Action: func(c *cli.Context) error {
dockerContextStore := context.NewDefaultDockerContextStore()
Before: internal.SubCommandBefore,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
dockerContextStore := contextPkg.NewDefaultDockerContextStore()
contexts, err := dockerContextStore.Store.List()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
tableColumns := []string{"name", "host", "user", "port"}
table := formatter.CreateTable(tableColumns)
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{"NAME", "HOST"}
table.Headers(headers...)
serverNames, err := config.ReadServerNames()
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var rows [][]string
for _, serverName := range serverNames {
var row []string
for _, ctx := range contexts {
endpoint, err := context.GetContextEndpoint(ctx)
for _, dockerCtx := range contexts {
endpoint, err := contextPkg.GetContextEndpoint(dockerCtx)
if err != nil && strings.Contains(err.Error(), "does not exist") {
// No local context found, we can continue safely
continue
}
if ctx.Name == serverName {
if dockerCtx.Name == serverName {
sp, err := ssh.ParseURL(endpoint)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if sp.Host == "" {
sp.Host = "unknown"
}
if sp.User == "" {
sp.User = "unknown"
}
if sp.Port == "" {
sp.Port = "unknown"
}
row = []string{serverName, sp.Host, sp.User, sp.Port}
row = []string{serverName, sp.Host}
rows = append(rows, row)
}
}
if len(row) == 0 {
if serverName == "default" {
row = []string{serverName, "local", "n/a", "n/a"}
row = []string{serverName, "local"}
} else {
row = []string{serverName, "unknown", "unknown", "unknown"}
row = []string{serverName, "unknown"}
}
rows = append(rows, row)
}
if problemsFilter {
for _, val := range row {
if val == "unknown" {
table.Append(row)
break
}
}
} else {
table.Append(row)
}
table.Row(row...)
}
if internal.MachineReadable {
table.JSONRender()
} else {
if problemsFilter && table.NumLines() == 0 {
logrus.Info("all servers wired up correctly 👏")
} else {
table.Render()
out, err := formatter.ToJSON(headers, rows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
}
fmt.Println(table)
return nil
},
}

View File

@ -7,15 +7,16 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
var allFilter bool
var allFilterFlag = &cli.BoolFlag{
Name: "all, a",
Name: "all",
Aliases: []string{"a"},
Usage: "Remove all unused images not just dangling ones",
Destination: &allFilter,
}
@ -23,79 +24,75 @@ var allFilterFlag = &cli.BoolFlag{
var volumesFilter bool
var volumesFilterFlag = &cli.BoolFlag{
Name: "volumes, v",
Name: "volumes",
Aliases: []string{"v"},
Usage: "Prune volumes. This will remove app data, Be Careful!",
Destination: &volumesFilter,
}
var serverPruneCommand = cli.Command{
Name: "prune",
Aliases: []string{"p"},
Usage: "Prune a managed server; Runs a docker system prune",
Description: `
Prunes unused containers, networks, and dangling images.
Name: "prune",
Aliases: []string{"p"},
Usage: "Prune resources on a server",
UsageText: "abra server prune <server> [options]",
Description: `Prunes unused containers, networks, and dangling images.
If passing "-v/--volumes" then volumes not connected to a deployed app will
also be removed. This can result in unwanted data loss if not used carefully.
`,
ArgsUsage: "[<server>]",
Use "-v/--volumes" to remove volumes that are not associated with a deployed
app. This can result in unwanted data loss if not used carefully.`,
Flags: []cli.Flag{
allFilterFlag,
volumesFilterFlag,
internal.DebugFlag,
internal.OfflineFlag,
internal.NoInputFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.ServerNameComplete,
Action: func(c *cli.Context) error {
serverName := internal.ValidateServer(c)
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.ServerNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
serverName := internal.ValidateServer(cmd)
cl, err := client.New(serverName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
var args filters.Args
ctx := context.Background()
cr, err := cl.ContainersPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
cntSpaceReclaimed := formatter.ByteCountSI(cr.SpaceReclaimed)
logrus.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
log.Infof("containers pruned: %d; space reclaimed: %s", len(cr.ContainersDeleted), cntSpaceReclaimed)
nr, err := cl.NetworksPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Infof("networks pruned: %d", len(nr.NetworksDeleted))
log.Infof("networks pruned: %d", len(nr.NetworksDeleted))
pruneFilters := filters.NewArgs()
if allFilter {
logrus.Debugf("removing all images, not only dangling ones")
log.Debugf("removing all images, not only dangling ones")
pruneFilters.Add("dangling", "false")
}
ir, err := cl.ImagesPrune(ctx, pruneFilters)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
imgSpaceReclaimed := formatter.ByteCountSI(ir.SpaceReclaimed)
logrus.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
log.Infof("images pruned: %d; space reclaimed: %s", len(ir.ImagesDeleted), imgSpaceReclaimed)
if volumesFilter {
vr, err := cl.VolumesPrune(ctx, args)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
volSpaceReclaimed := formatter.ByteCountSI(vr.SpaceReclaimed)
logrus.Infof("volumes pruned: %d; space reclaimed: %s", len(vr.VolumesDeleted), volSpaceReclaimed)
log.Infof("volumes pruned: %d; space reclaimed: %s", len(vr.VolumesDeleted), volSpaceReclaimed)
}
return nil

View File

@ -1,6 +1,7 @@
package server
import (
"context"
"os"
"path/filepath"
@ -8,40 +9,35 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
var serverRemoveCommand = cli.Command{
Name: "remove",
Aliases: []string{"rm"},
ArgsUsage: "<server>",
UsageText: "abra server remove <domain> [options]",
Usage: "Remove a managed server",
Description: `Remove a managed server.
Abra will remove the internal bookkeeping (~/.abra/servers/...) and underlying
client connection context. This server will then be lost in time, like tears in
rain.
`,
Flags: []cli.Flag{
internal.DebugFlag,
internal.NoInputFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
BashComplete: autocomplete.ServerNameComplete,
Action: func(c *cli.Context) error {
serverName := internal.ValidateServer(c)
Abra will remove the internal bookkeeping ($ABRA_DIR/servers/...) and
underlying client connection context. This server will then be lost in time,
like tears in rain.`,
Before: internal.SubCommandBefore,
ShellComplete: autocomplete.ServerNameComplete,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
serverName := internal.ValidateServer(cmd)
if err := client.DeleteContext(serverName); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName)
log.Infof("%s is now lost in time, like tears in rain", serverName)
return nil
},

View File

@ -1,18 +1,19 @@
package server
import (
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
// ServerCommand defines the `abra server` command and its subcommands
var ServerCommand = cli.Command{
Name: "server",
Aliases: []string{"s"},
Usage: "Manage servers",
Subcommands: []cli.Command{
serverAddCommand,
serverListCommand,
serverRemoveCommand,
serverPruneCommand,
Name: "server",
Aliases: []string{"s"},
Usage: "Manage servers",
UsageText: "abra server [command] [arguments] [options]",
Commands: []*cli.Command{
&serverAddCommand,
&serverListCommand,
&serverRemoveCommand,
&serverPruneCommand,
},
}

View File

@ -8,76 +8,80 @@ import (
"strings"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
charmLog "github.com/charmbracelet/log"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerclient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"coopcloud.tech/abra/pkg/log"
"github.com/urfave/cli/v3"
)
const SERVER = "localhost"
var majorUpdate bool
var majorFlag = &cli.BoolFlag{
Name: "major, m",
Name: "major",
Aliases: []string{"m"},
Usage: "Also check for major updates",
Destination: &majorUpdate,
}
var updateAll bool
var allFlag = &cli.BoolFlag{
Name: "all, a",
Name: "all",
Aliases: []string{"a"},
Usage: "Update all deployed apps",
Destination: &updateAll,
}
// Notify checks for available upgrades
var Notify = cli.Command{
Name: "notify",
Aliases: []string{"n"},
Usage: "Check for available upgrades",
Name: "notify",
Aliases: []string{"n"},
Usage: "Check for available upgrades",
UsageText: "kadabra notify [options]",
Flags: []cli.Flag{
internal.DebugFlag,
majorFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
It reads the deployed app versions and looks for new versions in the recipe
catalogue. If a new patch/minor version is available, a notification is
printed. To include major versions use the --major flag.
`,
Action: func(c *cli.Context) error {
Description: `Notify on new versions for deployed apps.
If a new patch/minor version is available, a notification is printed.
Use "--major" to include new major versions.`,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
cl, err := client.New("default")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
stacks, err := stack.GetStacks(cl)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, stackInfo := range stacks {
stackName := stackInfo.Name
recipeName, err := getLabel(cl, stackName, "recipe")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if recipeName != "" {
_, err = getLatestUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
}
@ -91,36 +95,37 @@ var UpgradeApp = cli.Command{
Name: "upgrade",
Aliases: []string{"u"},
Usage: "Upgrade apps",
ArgsUsage: "<stack-name> <recipe>",
UsageText: "kadabra notify <stack> <recipe> [options]",
Flags: []cli.Flag{
internal.DebugFlag,
internal.ChaosFlag,
majorFlag,
allFlag,
internal.OfflineFlag,
},
Before: internal.SubCommandBefore,
Description: `
Upgrade an app by specifying its stack name and recipe. By passing "--all"
instead, every deployed app is upgraded. For each apps with enabled auto
updates the deployed version is compared with the current recipe catalogue
version. If a new patch/minor version is available, the app is upgraded. To
include major versions use the "--major" flag. Don't do that, it will probably
break things. Only apps that are not deployed with "--chaos" are upgraded, to
update chaos deployments use the "--chaos" flag. Use it with care.
`,
Action: func(c *cli.Context) error {
Description: `Upgrade an app by specifying stack name and recipe.
Use "--all" to upgrade every deployed app.
For each app with auto updates enabled, the deployed version is compared with
the current recipe catalogue version. If a new patch/minor version is
available, the app is upgraded.
To include major versions use the "--major" flag. You probably don't want that
as it will break things. Only apps that are not deployed with "--chaos" are
upgraded, to update chaos deployments use the "--chaos" flag. Use it with care.`,
HideHelp: true,
Action: func(ctx context.Context, cmd *cli.Command) error {
cl, err := client.New("default")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if !updateAll {
stackName := c.Args().Get(0)
recipeName := c.Args().Get(1)
stackName := cmd.Args().Get(0)
recipeName := cmd.Args().Get(1)
err = tryUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
return nil
@ -128,19 +133,19 @@ update chaos deployments use the "--chaos" flag. Use it with care.
stacks, err := stack.GetStacks(cl)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
for _, stackInfo := range stacks {
stackName := stackInfo.Name
recipeName, err := getLabel(cl, stackName, "recipe")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
err = tryUpgrade(cl, stackName, recipeName)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
}
@ -165,7 +170,7 @@ func getLabel(cl *dockerclient.Client, stackName string, label string) (string,
}
}
logrus.Debugf("no %s label found for %s", label, stackName)
log.Debugf("no %s label found for %s", label, stackName)
return "", nil
}
@ -186,13 +191,13 @@ func getBoolLabel(cl *dockerclient.Client, stackName string, label string) (bool
return value, nil
}
logrus.Debugf("Boolean label %s could not be found for %s, set default to false.", label, stackName)
log.Debugf("boolean label %s could not be found for %s, set default to false.", label, stackName)
return false, nil
}
// getEnv reads env variables from docker services.
func getEnv(cl *dockerclient.Client, stackName string) (config.AppEnv, error) {
func getEnv(cl *dockerclient.Client, stackName string) (envfile.AppEnv, error) {
envMap := make(map[string]string)
filter := filters.NewArgs()
filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
@ -207,12 +212,12 @@ func getEnv(cl *dockerclient.Client, stackName string) (config.AppEnv, error) {
for _, envString := range envList {
splitString := strings.SplitN(envString, "=", 2)
if len(splitString) != 2 {
logrus.Debugf("can't separate key from value: %s (this variable is probably unset)", envString)
log.Debugf("can't separate key from value: %s (this variable is probably unset)", envString)
continue
}
k := splitString[0]
v := splitString[1]
logrus.Debugf("For %s read env %s with value: %s from docker service", stackName, k, v)
log.Debugf("for %s read env %s with value: %s from docker service", stackName, k, v)
envMap[k] = v
}
}
@ -234,14 +239,14 @@ func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName stri
}
if len(availableUpgrades) == 0 {
logrus.Debugf("no available upgrades for %s", stackName)
log.Debugf("no available upgrades for %s", stackName)
return "", nil
}
var chosenUpgrade string
if len(availableUpgrades) > 0 {
chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
logrus.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade)
log.Infof("%s (%s) can be upgraded from version %s to %s", stackName, recipeName, deployedVersion, chosenUpgrade)
}
return chosenUpgrade, nil
@ -249,22 +254,22 @@ func getLatestUpgrade(cl *dockerclient.Client, stackName string, recipeName stri
// getDeployedVersion returns the currently deployed version of an app.
func getDeployedVersion(cl *dockerclient.Client, stackName string, recipeName string) (string, error) {
logrus.Debugf("Retrieve deployed version whether %s is already deployed", stackName)
log.Debugf("retrieve deployed version whether %s is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(context.Background(), cl, stackName)
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
return "", err
}
if !isDeployed {
if !deployMeta.IsDeployed {
return "", fmt.Errorf("%s is not deployed?", stackName)
}
if deployedVersion == "unknown" {
if deployMeta.Version == "unknown" {
return "", fmt.Errorf("failed to determine deployed version of %s", stackName)
}
return deployedVersion, nil
return deployMeta.Version, nil
}
// getAvailableUpgrades returns all available versions of an app that are newer
@ -283,7 +288,7 @@ func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName
}
if len(versions) == 0 {
logrus.Warnf("no published releases for %s in the recipe catalogue?", recipeName)
log.Warnf("no published releases for %s in the recipe catalogue?", recipeName)
return nil, nil
}
@ -309,29 +314,27 @@ func getAvailableUpgrades(cl *dockerclient.Client, stackName string, recipeName
}
}
logrus.Debugf("Available updates for %s: %s", stackName, availableUpgrades)
log.Debugf("available updates for %s: %s", stackName, availableUpgrades)
return availableUpgrades, nil
}
// processRecipeRepoVersion clones, pulls, checks out the version and lints the
// recipe repository.
func processRecipeRepoVersion(recipeName, version string) error {
if err := recipe.EnsureExists(recipeName); err != nil {
func processRecipeRepoVersion(r recipe.Recipe, version string) error {
if err := r.EnsureExists(); err != nil {
return err
}
if err := recipe.EnsureUpToDate(recipeName); err != nil {
if err := r.EnsureUpToDate(); err != nil {
return err
}
if err := recipe.EnsureVersion(recipeName, version); err != nil {
if _, err := r.EnsureVersion(version); err != nil {
return err
}
if r, err := recipe.Get(recipeName, internal.Offline); err != nil {
return err
} else if err := lint.LintForErrors(r); err != nil {
if err := lint.LintForErrors(r); err != nil {
return err
}
@ -339,15 +342,14 @@ func processRecipeRepoVersion(recipeName, version string) error {
}
// mergeAbraShEnv merges abra.sh env vars into the app env vars.
func mergeAbraShEnv(recipeName string, env config.AppEnv) error {
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, recipeName, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
func mergeAbraShEnv(recipe recipe.Recipe, env envfile.AppEnv) error {
abraShEnv, err := envfile.ReadAbraShEnvVars(recipe.AbraShPath)
if err != nil {
return err
}
for k, v := range abraShEnv {
logrus.Debugf("read v:%s k: %s", v, k)
log.Debugf("read v:%s k: %s", v, k)
env[k] = v
}
@ -355,32 +357,33 @@ func mergeAbraShEnv(recipeName string, env config.AppEnv) error {
}
// createDeployConfig merges and enriches the compose config for the deployment.
func createDeployConfig(recipeName string, stackName string, env config.AppEnv) (*composetypes.Config, stack.Deploy, error) {
func createDeployConfig(r recipe.Recipe, stackName string, env envfile.AppEnv) (*composetypes.Config, stack.Deploy, error) {
env["STACK_NAME"] = stackName
deployOpts := stack.Deploy{
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
composeFiles, err := config.GetComposeFiles(recipeName, env)
composeFiles, err := r.GetComposeFiles(env)
if err != nil {
return nil, deployOpts, err
}
deployOpts.Composefiles = composeFiles
compose, err := config.GetAppComposeConfig(stackName, deployOpts, env)
compose, err := appPkg.GetAppComposeConfig(stackName, deployOpts, env)
if err != nil {
return nil, deployOpts, err
}
config.ExposeAllEnv(stackName, compose, env)
appPkg.ExposeAllEnv(stackName, compose, env)
// after the upgrade the deployment won't be in chaos state anymore
config.SetChaosLabel(compose, stackName, false)
config.SetRecipeLabel(compose, stackName, recipeName)
config.SetUpdateLabel(compose, stackName, env)
appPkg.SetChaosLabel(compose, stackName, false)
appPkg.SetRecipeLabel(compose, stackName, r.Name)
appPkg.SetUpdateLabel(compose, stackName, env)
return compose, deployOpts, nil
}
@ -388,7 +391,7 @@ func createDeployConfig(recipeName string, stackName string, env config.AppEnv)
// tryUpgrade performs the upgrade if all the requirements are fulfilled.
func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
if recipeName == "" {
logrus.Debugf("don't update %s due to missing recipe name", stackName)
log.Debugf("don't update %s due to missing recipe name", stackName)
return nil
}
@ -398,7 +401,7 @@ func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
}
if chaos && !internal.Chaos {
logrus.Debugf("don't update %s due to chaos deployment", stackName)
log.Debugf("don't update %s due to chaos deployment", stackName)
return nil
}
@ -408,7 +411,7 @@ func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
}
if !updatesEnabled {
logrus.Debugf("don't update %s due to disabled auto updates or missing ENABLE_AUTO_UPDATE env", stackName)
log.Debugf("don't update %s due to disabled auto updates or missing ENABLE_AUTO_UPDATE env", stackName)
return nil
}
@ -418,7 +421,7 @@ func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
}
if upgradeVersion == "" {
logrus.Debugf("don't update %s due to no new version", stackName)
log.Debugf("don't update %s due to no new version", stackName)
return nil
}
@ -428,71 +431,82 @@ func tryUpgrade(cl *dockerclient.Client, stackName, recipeName string) error {
}
// upgrade performs all necessary steps to upgrade an app.
func upgrade(cl *dockerclient.Client, stackName, recipeName,
upgradeVersion string) error {
func upgrade(cl *dockerclient.Client, stackName, recipeName, upgradeVersion string) error {
env, err := getEnv(cl, stackName)
if err != nil {
return err
}
app := config.App{
app := appPkg.App{
Name: stackName,
Recipe: recipeName,
Recipe: recipe.Get(recipeName),
Server: SERVER,
Env: env,
}
if err = processRecipeRepoVersion(recipeName, upgradeVersion); err != nil {
r := recipe.Get(recipeName)
if err = processRecipeRepoVersion(r, upgradeVersion); err != nil {
return err
}
if err = mergeAbraShEnv(recipeName, app.Env); err != nil {
if err = mergeAbraShEnv(app.Recipe, app.Env); err != nil {
return err
}
compose, deployOpts, err := createDeployConfig(recipeName, stackName, app.Env)
compose, deployOpts, err := createDeployConfig(r, stackName, app.Env)
if err != nil {
return err
}
logrus.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)
log.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)
err = stack.RunDeploy(cl, deployOpts, compose, stackName, true)
return err
}
func newAbraApp(version, commit string) *cli.App {
app := &cli.App{
Name: "kadabra",
Usage: `The Co-op Cloud auto-updater
____ ____ _ _
/ ___|___ ___ _ __ / ___| | ___ _ _ __| |
| | / _ \ _____ / _ \| '_ \ | | | |/ _ \| | | |/ _' |
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|_|
`,
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []cli.Command{
Notify,
UpgradeApp,
func newKadabraApp(version, commit string) *cli.Command {
app := &cli.Command{
Name: "kadabra",
Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Usage: "The Co-op Cloud auto-updater 🤖 🚀",
UsageText: "kadabra [command] [options]",
UseShortOptionHandling: true,
HideHelpCommand: true,
Flags: []cli.Flag{
// NOTE(d1): "GLOBAL OPTIONS" flags
internal.DebugFlag,
},
Commands: []*cli.Command{
&Notify,
&UpgradeApp,
},
}
app.Before = func(c *cli.Context) error {
logrus.Debugf("kadabra version %s, commit %s", version, commit)
app.Before = func(ctx context.Context, cmd *cli.Command) error {
log.Logger.SetStyles(log.Styles())
charmLog.SetDefault(log.Logger)
log.Debugf("kadabra version %s, commit %s", version, commit)
return nil
}
cli.HelpFlag = &cli.BoolFlag{
Name: "help",
Aliases: []string{"h, H"},
Usage: "Show help",
}
return app
}
// RunApp runs CLI abra app.
func RunApp(version, commit string) {
app := newAbraApp(version, commit)
app := newKadabraApp(version, commit)
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
if err := app.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}

153
go.mod
View File

@ -2,119 +2,138 @@ module coopcloud.tech/abra
go 1.21
replace github.com/urfave/cli/v3 => github.com/urfave/cli/v3 v3.0.0-alpha9.1.0.20241019193437-5053ec708a44
require (
coopcloud.tech/tagcmp v0.0.0-20211103052201-885b22f77d52
coopcloud.tech/tagcmp v0.0.0-20230809071031-eb3e7758d4eb
git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
github.com/docker/cli v24.0.7+incompatible
github.com/docker/distribution v2.8.3+incompatible
github.com/docker/docker v24.0.7+incompatible
github.com/charmbracelet/lipgloss v0.11.1
github.com/charmbracelet/log v0.4.0
github.com/distribution/reference v0.6.0
github.com/docker/cli v27.0.3+incompatible
github.com/docker/docker v27.0.3+incompatible
github.com/docker/go-units v0.5.0
github.com/go-git/go-git/v5 v5.10.0
github.com/google/go-cmp v0.5.9
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-cmp v0.6.0
github.com/moby/sys/signal v0.7.0
github.com/moby/term v0.5.0
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1
github.com/schollz/progressbar/v3 v3.14.1
github.com/sirupsen/logrus v1.9.3
github.com/schollz/progressbar/v3 v3.14.4
github.com/urfave/cli/v3 v3.0.0-alpha9
golang.org/x/term v0.22.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.1
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/hcsshim v0.9.2 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/x/ansi v0.1.3 // indirect
github.com/cloudflare/circl v1.3.9 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.14.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.0.3 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/user v0.1.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/runc v1.1.0 // indirect
github.com/opencontainers/runc v1.1.13 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/skeema/knownhosts v1.2.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/term v0.14.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
golang.org/x/tools v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect
github.com/buger/goterm v1.0.4
github.com/containerd/containerd v1.5.9 // indirect
github.com/containerd/containerd v1.7.19 // indirect
github.com/containers/image v3.0.2+incompatible
github.com/containers/storage v1.38.2 // indirect
github.com/decentral1se/passgen v1.0.1
github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/fvbommel/sortorder v1.0.2 // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/fvbommel/sortorder v1.1.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/pgzip v1.2.6
github.com/moby/patternmatcher v0.5.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202193544-a5463b7f9c84 // indirect
github.com/prometheus/client_golang v1.16.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/spf13/cobra v1.3.0 // indirect
github.com/stretchr/testify v1.8.4
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/stretchr/testify v1.9.0
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/urfave/cli v1.22.9
github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b // indirect
golang.org/x/sys v0.14.0
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.22.0
)

649
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,624 @@
package app
import (
"bufio"
"fmt"
"os"
"path"
"regexp"
"sort"
"strings"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"github.com/sirupsen/logrus"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/abra/pkg/log"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/filters"
"github.com/schollz/progressbar/v3"
)
// Get retrieves an app
func Get(appName string) (config.App, error) {
files, err := config.LoadAppFiles("")
func Get(appName string) (App, error) {
files, err := LoadAppFiles("")
if err != nil {
return config.App{}, err
return App{}, err
}
app, err := config.GetApp(files, appName)
app, err := GetApp(files, appName)
if err != nil {
return config.App{}, err
return App{}, err
}
logrus.Debugf("retrieved %s for %s", app, appName)
log.Debugf("retrieved %s for %s", app, appName)
return app, nil
}
// deployedServiceSpec represents a deployed service of an app.
type deployedServiceSpec struct {
Name string
Version string
// GetApp loads an apps settings, reading it from file, in preparation to use
// it. It should only be used when ready to use the env file to keep IO
// operations down.
func GetApp(apps AppFiles, name AppName) (App, error) {
appFile, exists := apps[name]
if !exists {
return App{}, fmt.Errorf("cannot find app with name %s", name)
}
app, err := ReadAppEnvFile(appFile, name)
if err != nil {
return App{}, err
}
return app, nil
}
// VersionSpec represents a deployed app and associated metadata.
type VersionSpec map[string]deployedServiceSpec
// GetApps returns a slice of Apps with their env files read from a given
// slice of AppFiles.
func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
var apps []App
// ParseServiceName parses a $STACK_NAME_$SERVICE_NAME service label.
func ParseServiceName(label string) string {
idx := strings.LastIndex(label, "_")
serviceName := label[idx+1:]
logrus.Debugf("parsed %s as service name from %s", serviceName, label)
return serviceName
for name := range appFiles {
app, err := GetApp(appFiles, name)
if err != nil {
return nil, err
}
if recipeFilter != "" {
if app.Recipe.Name == recipeFilter {
apps = append(apps, app)
}
} else {
apps = append(apps, app)
}
}
return apps, nil
}
// App reprents an app with its env file read into memory
type App struct {
Name AppName
Recipe recipe.Recipe
Domain string
Env envfile.AppEnv
Server string
Path string
}
// Type aliases to make code hints easier to understand
// AppName is AppName
type AppName = string
// AppFile represents app env files on disk without reading the contents
type AppFile struct {
Path string
Server string
}
// AppFiles is a slice of appfiles
type AppFiles map[AppName]AppFile
// See documentation of config.StackName
func (a App) StackName() string {
if _, exists := a.Env["STACK_NAME"]; exists {
return a.Env["STACK_NAME"]
}
stackName := StackName(a.Name)
a.Env["STACK_NAME"] = stackName
return stackName
}
// StackName gets whatever the docker safe (uses the right delimiting
// character, e.g. "_") stack name is for the app. In general, you don't want
// to use this to show anything to end-users, you want use a.Name instead.
func StackName(appName string) string {
stackName := SanitiseAppName(appName)
if len(stackName) > config.MAX_SANITISED_APP_NAME_LENGTH {
log.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:config.MAX_SANITISED_APP_NAME_LENGTH])
stackName = stackName[:config.MAX_SANITISED_APP_NAME_LENGTH]
}
return stackName
}
// Filters retrieves app filters for querying the container runtime. By default
// it filters on all services in the app. It is also possible to pass an
// otional list of service names, which get filtered instead.
//
// Due to upstream issues, filtering works different depending on what you're
// querying. So, for example, secrets don't work with regex! The caller needs
// to implement their own validation that the right secrets are matched. In
// order to handle these cases, we provide the `appendServiceNames` /
// `exactMatch` modifiers.
func (a App) Filters(appendServiceNames, exactMatch bool, services ...string) (filters.Args, error) {
filters := filters.NewArgs()
if len(services) > 0 {
for _, serviceName := range services {
filters.Add("name", ServiceFilter(a.StackName(), serviceName, exactMatch))
}
return filters, nil
}
// When not appending the service name, just add one filter for the whole
// stack.
if !appendServiceNames {
f := fmt.Sprintf("%s", a.StackName())
if exactMatch {
f = fmt.Sprintf("^%s", f)
}
filters.Add("name", f)
return filters, nil
}
composeFiles, err := a.Recipe.GetComposeFiles(a.Env)
if err != nil {
return filters, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(a.Recipe.Name, opts, a.Env)
if err != nil {
return filters, err
}
for _, service := range compose.Services {
f := ServiceFilter(a.StackName(), service.Name, exactMatch)
filters.Add("name", f)
}
return filters, nil
}
// ServiceFilter creates a filter string for filtering a service in the docker
// container runtime. When exact match is true, it uses regex to match the
// string exactly.
func ServiceFilter(stack, service string, exact bool) string {
if exact {
return fmt.Sprintf("^%s_%s", stack, service)
}
return fmt.Sprintf("%s_%s", stack, service)
}
// ByServer sort a slice of Apps
type ByServer []App
func (a ByServer) Len() int { return len(a) }
func (a ByServer) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServer) Less(i, j int) bool {
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByServerAndRecipe sort a slice of Apps
type ByServerAndRecipe []App
func (a ByServerAndRecipe) Len() int { return len(a) }
func (a ByServerAndRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServerAndRecipe) Less(i, j int) bool {
if a[i].Server == a[j].Server {
return strings.ToLower(a[i].Recipe.Name) < strings.ToLower(a[j].Recipe.Name)
}
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByRecipe sort a slice of Apps
type ByRecipe []App
func (a ByRecipe) Len() int { return len(a) }
func (a ByRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByRecipe) Less(i, j int) bool {
return strings.ToLower(a[i].Recipe.Name) < strings.ToLower(a[j].Recipe.Name)
}
// ByName sort a slice of Apps
type ByName []App
func (a ByName) Len() int { return len(a) }
func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByName) Less(i, j int) bool {
return strings.ToLower(a[i].Name) < strings.ToLower(a[j].Name)
}
func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
env, err := envfile.ReadEnv(appFile.Path)
if err != nil {
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error())
}
log.Debugf("read env %s from %s", env, appFile.Path)
app, err := NewApp(env, name, appFile)
if err != nil {
return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error())
}
return app, nil
}
// NewApp creates new App object
func NewApp(env envfile.AppEnv, name string, appFile AppFile) (App, error) {
domain := env["DOMAIN"]
recipeName, exists := env["RECIPE"]
if !exists {
recipeName, exists = env["TYPE"]
if !exists {
return App{}, fmt.Errorf("%s is missing the TYPE env var?", name)
}
}
return App{
Name: name,
Domain: domain,
Recipe: recipe.Get(recipeName),
Env: env,
Server: appFile.Server,
Path: appFile.Path,
}, nil
}
// LoadAppFiles gets all app files for a given set of servers or all servers.
func LoadAppFiles(servers ...string) (AppFiles, error) {
appFiles := make(AppFiles)
if len(servers) == 1 {
if servers[0] == "" {
// Empty servers flag, one string will always be passed
var err error
servers, err = config.GetAllFoldersInDirectory(config.SERVERS_DIR)
if err != nil {
return appFiles, err
}
}
}
log.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", "))
for _, server := range servers {
serverDir := path.Join(config.SERVERS_DIR, server)
files, err := config.GetAllFilesInDirectory(serverDir)
if err != nil {
return appFiles, fmt.Errorf("server %s doesn't exist? Run \"abra server ls\" to check", server)
}
for _, file := range files {
appName := strings.TrimSuffix(file.Name(), ".env")
appFilePath := path.Join(config.SERVERS_DIR, server, file.Name())
appFiles[appName] = AppFile{
Path: appFilePath,
Server: server,
}
}
}
return appFiles, nil
}
// GetAppServiceNames retrieves a list of app service names.
func GetAppServiceNames(appName string) ([]string, error) {
var serviceNames []string
appFiles, err := LoadAppFiles("")
if err != nil {
return serviceNames, err
}
app, err := GetApp(appFiles, appName)
if err != nil {
return serviceNames, err
}
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
return serviceNames, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(app.Recipe.Name, opts, app.Env)
if err != nil {
return serviceNames, err
}
for _, service := range compose.Services {
serviceNames = append(serviceNames, service.Name)
}
return serviceNames, nil
}
// GetAppNames retrieves a list of app names.
func GetAppNames() ([]string, error) {
var appNames []string
appFiles, err := LoadAppFiles("")
if err != nil {
return appNames, err
}
apps, err := GetApps(appFiles, "")
if err != nil {
return appNames, err
}
for _, app := range apps {
appNames = append(appNames, app.Name)
}
return appNames, nil
}
// TemplateAppEnvSample copies the example env file for the app into the users
// env files.
func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error {
envSample, err := os.ReadFile(r.SampleEnvPath)
if err != nil {
return err
}
appEnvPath := path.Join(config.ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
return fmt.Errorf("%s already exists?", appEnvPath)
}
err = os.WriteFile(appEnvPath, envSample, 0o664)
if err != nil {
return err
}
read, err := os.ReadFile(appEnvPath)
if err != nil {
return err
}
newContents := strings.Replace(string(read), r.Name+".example.com", domain, -1)
err = os.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return err
}
log.Debugf("copied & templated %s to %s", r.SampleEnvPath, appEnvPath)
return nil
}
// SanitiseAppName makes a app name usable with Docker by replacing illegal
// characters.
func SanitiseAppName(name string) string {
return strings.ReplaceAll(name, ".", "_")
}
// GetAppStatuses queries servers to check the deployment status of given apps.
func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]string, error) {
statuses := make(map[string]map[string]string)
servers := make(map[string]struct{})
for _, app := range apps {
if _, ok := servers[app.Server]; !ok {
servers[app.Server] = struct{}{}
}
}
var bar *progressbar.ProgressBar
if !MachineReadable {
bar = formatter.CreateProgressbar(len(servers), "querying remote servers...")
}
ch := make(chan stack.StackStatus, len(servers))
for server := range servers {
cl, err := client.New(server)
if err != nil {
return statuses, err
}
go func(s string) {
ch <- stack.GetAllDeployedServices(cl, s)
if !MachineReadable {
bar.Add(1)
}
}(server)
}
for range servers {
status := <-ch
if status.Err != nil {
return statuses, status.Err
}
for _, service := range status.Services {
result := make(map[string]string)
name := service.Spec.Labels[convert.LabelNamespace]
if _, ok := statuses[name]; !ok {
result["status"] = "deployed"
}
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", name)
chaos, ok := service.Spec.Labels[labelKey]
if ok {
result["chaos"] = chaos
}
labelKey = fmt.Sprintf("coop-cloud.%s.chaos-version", name)
if chaosVersion, ok := service.Spec.Labels[labelKey]; ok {
result["chaosVersion"] = chaosVersion
}
labelKey = fmt.Sprintf("coop-cloud.%s.autoupdate", name)
if autoUpdate, ok := service.Spec.Labels[labelKey]; ok {
result["autoUpdate"] = autoUpdate
} else {
result["autoUpdate"] = "false"
}
labelKey = fmt.Sprintf("coop-cloud.%s.version", name)
if version, ok := service.Spec.Labels[labelKey]; ok {
result["version"] = version
} else {
continue
}
statuses[name] = result
}
}
log.Debugf("retrieved app statuses: %s", statuses)
return statuses, nil
}
// GetAppComposeConfig retrieves a compose specification for a recipe. This
// specification is the result of a merge of all the compose.**.yml files in
// the recipe repository.
func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv) (*composetypes.Config, error) {
compose, err := loader.LoadComposefile(opts, appEnv)
if err != nil {
return &composetypes.Config{}, err
}
log.Debugf("retrieved %s for %s", compose.Filename, recipe)
return compose, nil
}
// ExposeAllEnv exposes all env variables to the app container
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv envfile.AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debugf("add the following environment to the app service config of %s:", stackName)
for k, v := range appEnv {
_, exists := service.Environment[k]
if !exists {
value := v
service.Environment[k] = &value
log.Debugf("add env var: %s value: %s to %s", k, value, stackName)
}
}
}
}
}
func CheckEnv(app App) ([]envfile.EnvVar, error) {
var envVars []envfile.EnvVar
envSample, err := app.Recipe.SampleEnv()
if err != nil {
return envVars, err
}
var keys []string
for key := range envSample {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, ok := app.Env[key]; ok {
envVars = append(envVars, envfile.EnvVar{Name: key, Present: true})
} else {
envVars = append(envVars, envfile.EnvVar{Name: key, Present: false})
}
}
return envVars, nil
}
// ReadAbraShCmdNames reads the names of commands.
func ReadAbraShCmdNames(abraSh string) ([]string, error) {
var cmdNames []string
file, err := os.Open(abraSh)
if err != nil {
if os.IsNotExist(err) {
return cmdNames, nil
}
return cmdNames, err
}
defer file.Close()
cmdNameRegex, err := regexp.Compile(`(\w+)(\(\).*\{)`)
if err != nil {
return cmdNames, err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
matches := cmdNameRegex.FindStringSubmatch(line)
if len(matches) > 0 {
cmdNames = append(cmdNames, matches[1])
}
}
if len(cmdNames) > 0 {
log.Debugf("read %s from %s", strings.Join(cmdNames, " "), abraSh)
} else {
log.Debugf("read 0 command names from %s", abraSh)
}
return cmdNames, nil
}
func (a App) WriteRecipeVersion(version string, dryRun bool) error {
file, err := os.Open(a.Path)
if err != nil {
return err
}
defer file.Close()
skipped := false
scanner := bufio.NewScanner(file)
lines := []string{}
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
lines = append(lines, line)
continue
}
if strings.HasPrefix(line, "#") {
lines = append(lines, line)
continue
}
if strings.Contains(line, version) {
skipped = true
lines = append(lines, line)
continue
}
splitted := strings.Split(line, ":")
line = fmt.Sprintf("%s:%s", splitted[0], version)
lines = append(lines, line)
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
if !dryRun {
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
log.Fatal(err)
}
} else {
log.Debugf("skipping writing version %s because dry run", version)
}
if !skipped {
log.Infof("version %s saved to %s.env", version, a.Domain)
} else {
log.Debugf("skipping version %s write as already exists in %s.env", version, a.Domain)
}
return nil
}

View File

@ -1,4 +1,4 @@
package config_test
package app_test
import (
"encoding/json"
@ -6,46 +6,49 @@ import (
"reflect"
"testing"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/recipe"
testPkg "coopcloud.tech/abra/pkg/test"
"github.com/docker/docker/api/types/filters"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
)
func TestNewApp(t *testing.T) {
app, err := config.NewApp(ExpectedAppEnv, AppName, ExpectedAppFile)
app, err := appPkg.NewApp(testPkg.ExpectedAppEnv, testPkg.AppName, testPkg.ExpectedAppFile)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(app, ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, ExpectedApp)
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
}
}
func TestReadAppEnvFile(t *testing.T) {
app, err := config.ReadAppEnvFile(ExpectedAppFile, AppName)
app, err := appPkg.ReadAppEnvFile(testPkg.ExpectedAppFile, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(app, ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, ExpectedApp)
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
}
}
func TestGetApp(t *testing.T) {
app, err := config.GetApp(ExpectedAppFiles, AppName)
app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(app, ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, ExpectedApp)
if !reflect.DeepEqual(app, testPkg.ExpectedApp) {
t.Fatalf("did not get expected app type. Expected: %s; Got: %s", app, testPkg.ExpectedApp)
}
}
func TestGetComposeFiles(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
@ -57,32 +60,32 @@ func TestGetComposeFiles(t *testing.T) {
{
map[string]string{},
[]string{
fmt.Sprintf("%s/%s/compose.yml", config.RECIPES_DIR, r.Name),
fmt.Sprintf("%s/compose.yml", r.Dir),
},
},
{
map[string]string{"COMPOSE_FILE": "compose.yml"},
[]string{
fmt.Sprintf("%s/%s/compose.yml", config.RECIPES_DIR, r.Name),
fmt.Sprintf("%s/compose.yml", r.Dir),
},
},
{
map[string]string{"COMPOSE_FILE": "compose.extra_secret.yml"},
[]string{
fmt.Sprintf("%s/%s/compose.extra_secret.yml", config.RECIPES_DIR, r.Name),
fmt.Sprintf("%s/compose.extra_secret.yml", r.Dir),
},
},
{
map[string]string{"COMPOSE_FILE": "compose.yml:compose.extra_secret.yml"},
[]string{
fmt.Sprintf("%s/%s/compose.yml", config.RECIPES_DIR, r.Name),
fmt.Sprintf("%s/%s/compose.extra_secret.yml", config.RECIPES_DIR, r.Name),
fmt.Sprintf("%s/compose.yml", r.Dir),
fmt.Sprintf("%s/compose.extra_secret.yml", r.Dir),
},
},
}
for _, test := range tests {
composeFiles, err := config.GetComposeFiles(r.Name, test.appEnv)
composeFiles, err := r.GetComposeFiles(test.appEnv)
if err != nil {
t.Fatal(err)
}
@ -91,8 +94,8 @@ func TestGetComposeFiles(t *testing.T) {
}
func TestGetComposeFilesError(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
@ -103,7 +106,7 @@ func TestGetComposeFilesError(t *testing.T) {
}
for _, test := range tests {
_, err := config.GetComposeFiles(r.Name, test.appEnv)
_, err := r.GetComposeFiles(test.appEnv)
if err == nil {
t.Fatalf("should have failed: %v", test.appEnv)
}
@ -112,16 +115,16 @@ func TestGetComposeFilesError(t *testing.T) {
func TestFilters(t *testing.T) {
oldDir := config.RECIPES_DIR
config.RECIPES_DIR = "./testdir"
config.RECIPES_DIR = "./testdata"
defer func() {
config.RECIPES_DIR = oldDir
}()
app, err := config.NewApp(config.AppEnv{
app, err := appPkg.NewApp(envfile.AppEnv{
"DOMAIN": "test.example.com",
"RECIPE": "test-recipe",
}, "test_example_com", config.AppFile{
Path: "./testdir/filtertest.end",
}, "test_example_com", appPkg.AppFile{
Path: "./testdata/filtertest.end",
Server: "local",
})
if err != nil {

88
pkg/app/compose.go Normal file
View File

@ -0,0 +1,88 @@
package app
import (
"fmt"
"strconv"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/log"
composetypes "github.com/docker/cli/cli/compose/types"
)
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
// to signal which recipe is connected to the deployed app
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debugf("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
service.Deploy.Labels[labelKey] = recipe
}
}
}
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
// to signal if the app is deployed in chaos mode
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debugf("set label 'coop-cloud.%s.chaos' to %v for %s", stackName, chaos, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", stackName)
service.Deploy.Labels[labelKey] = strconv.FormatBool(chaos)
}
}
}
// SetChaosVersionLabel adds the label 'coop-cloud.${STACK_NAME}.chaos-version=$(GIT_COMMIT)' to the app container
func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosVersion string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debugf("set label 'coop-cloud.%s.chaos-version' to %v for %s", stackName, chaosVersion, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos-version", stackName)
service.Deploy.Labels[labelKey] = chaosVersion
}
}
}
// SetUpdateLabel adds env ENABLE_AUTO_UPDATE as label to enable/disable the
// auto update process for this app. The default if this variable is not set is to disable
// the auto update process.
func SetUpdateLabel(compose *composetypes.Config, stackName string, appEnv envfile.AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
enable_auto_update, exists := appEnv["ENABLE_AUTO_UPDATE"]
if !exists {
enable_auto_update = "false"
}
log.Debugf("set label 'coop-cloud.%s.autoupdate' to %s for %s", stackName, enable_auto_update, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.autoupdate", stackName)
service.Deploy.Labels[labelKey] = enable_auto_update
}
}
}
// GetLabel reads docker labels in the format of "coop-cloud.${STACK_NAME}.${LABEL}" from the local compose files
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
for _, service := range compose.Services {
if service.Name == "app" {
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
log.Debugf("get label '%s'", labelKey)
if labelValue, ok := service.Deploy.Labels[labelKey]; ok {
return labelValue
}
}
}
log.Debugf("no %s label found for %s", label, stackName)
return ""
}
// GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value
func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) {
timeout := 50 // Default Timeout
var err error = nil
if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" {
log.Debugf("timeout label: %s", timeoutLabel)
timeout, err = strconv.Atoi(timeoutLabel)
}
return timeout, err
}

View File

@ -1,22 +1,23 @@
package autocomplete
import (
"context"
"fmt"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/urfave/cli/v3"
)
// AppNameComplete copletes app names.
func AppNameComplete(c *cli.Context) {
appNames, err := config.GetAppNames()
func AppNameComplete(ctx context.Context, cmd *cli.Command) {
appNames, err := app.GetAppNames()
if err != nil {
logrus.Warn(err)
log.Warn(err)
}
if c.NArg() > 0 {
if cmd.NArg() > 0 {
return
}
@ -26,7 +27,7 @@ func AppNameComplete(c *cli.Context) {
}
func ServiceNameComplete(appName string) {
serviceNames, err := config.GetAppServiceNames(appName)
serviceNames, err := app.GetAppServiceNames(appName)
if err != nil {
return
}
@ -36,13 +37,13 @@ func ServiceNameComplete(appName string) {
}
// RecipeNameComplete completes recipe names.
func RecipeNameComplete(c *cli.Context) {
func RecipeNameComplete(ctx context.Context, cmd *cli.Command) {
catl, err := recipe.ReadRecipeCatalogue(false)
if err != nil {
logrus.Warn(err)
log.Warn(err)
}
if c.NArg() > 0 {
if cmd.NArg() > 0 {
return
}
@ -55,7 +56,7 @@ func RecipeNameComplete(c *cli.Context) {
func RecipeVersionComplete(recipeName string) {
catl, err := recipe.ReadRecipeCatalogue(false)
if err != nil {
logrus.Warn(err)
log.Warn(err)
}
for _, v := range catl[recipeName].Versions {
@ -66,13 +67,13 @@ func RecipeVersionComplete(recipeName string) {
}
// ServerNameComplete completes server names.
func ServerNameComplete(c *cli.Context) {
files, err := config.LoadAppFiles("")
func ServerNameComplete(ctx context.Context, cmd *cli.Command) {
files, err := app.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if c.NArg() > 0 {
if cmd.NArg() > 0 {
return
}
@ -82,8 +83,8 @@ func ServerNameComplete(c *cli.Context) {
}
// SubcommandComplete completes sub-commands.
func SubcommandComplete(c *cli.Context) {
if c.NArg() > 0 {
func SubcommandComplete(ctx context.Context, cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}

View File

@ -8,21 +8,21 @@ import (
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// EnsureCatalogue ensures that the catalogue is cloned locally & present.
func EnsureCatalogue() error {
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
if _, err := os.Stat(catalogueDir); err != nil && os.IsNotExist(err) {
logrus.Warnf("local recipe catalogue is missing, retrieving now")
log.Warnf("local recipe catalogue is missing, retrieving now")
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
logrus.Debugf("cloned catalogue repository to %s", catalogueDir)
log.Debugf("cloned catalogue repository to %s", catalogueDir)
}
return nil
@ -57,7 +57,7 @@ func EnsureUpToDate() error {
if len(remotes) == 0 {
msg := "cannot ensure %s is up-to-date, no git remotes configured"
logrus.Debugf(msg, config.CATALOGUE_DIR)
log.Debugf(msg, config.CATALOGUE_DIR)
return nil
}
@ -82,7 +82,7 @@ func EnsureUpToDate() error {
}
}
logrus.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR)
log.Debugf("fetched latest git changes for %s", config.CATALOGUE_DIR)
return nil
}

View File

@ -10,17 +10,32 @@ import (
"time"
contextPkg "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/log"
sshPkg "coopcloud.tech/abra/pkg/ssh"
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// Conf is a Docker client configuration.
type Conf struct {
Timeout int
}
// Opt is a Docker client option.
type Opt func(c *Conf)
// WithTimeout specifies a timeout for a Docker client.
func WithTimeout(timeout int) Opt {
return func(c *Conf) {
c.Timeout = timeout
}
}
// New initiates a new Docker client. New client connections are validated so
// that we ensure connections via SSH to the daemon can succeed. It takes into
// account that you may only want the local client and not communicate via SSH.
// For this use-case, please pass "default" as the contextName.
func New(serverName string) (*client.Client, error) {
func New(serverName string, opts ...Opt) (*client.Client, error) {
var clientOpts []client.Opt
if serverName != "default" {
@ -34,7 +49,12 @@ func New(serverName string) (*client.Client, error) {
return nil, err
}
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint)
conf := &Conf{}
for _, opt := range opts {
opt(conf)
}
helper, err := commandconnPkg.NewConnectionHelper(ctxEndpoint, conf.Timeout)
if err != nil {
return nil, err
}
@ -65,7 +85,7 @@ func New(serverName string) (*client.Client, error) {
return nil, err
}
logrus.Debugf("created client for %s", serverName)
log.Debugf("created client for %s", serverName)
info, err := cl.Info(context.Background())
if err != nil {
@ -75,9 +95,9 @@ func New(serverName string) (*client.Client, error) {
if info.Swarm.LocalNodeState == "inactive" {
if serverName != "default" {
return cl, fmt.Errorf("swarm mode not enabled on %s?", serverName)
} else {
return cl, errors.New("swarm mode not enabled on local server?")
}
return cl, errors.New("swarm mode not enabled on local server?")
}
return cl, nil

View File

@ -5,28 +5,25 @@ import (
"fmt"
"coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/log"
commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn"
dConfig "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/context/docker"
contextStore "github.com/docker/cli/cli/context/store"
"github.com/sirupsen/logrus"
)
type Context = contextStore.Metadata
func CreateContext(contextName string, user string, port string) error {
host := contextName
if user != "" {
host = fmt.Sprintf("%s@%s", user, host)
}
if port != "" {
host = fmt.Sprintf("%s:%s", host, port)
}
host = fmt.Sprintf("ssh://%s", host)
// CreateContext creates a new Docker context.
func CreateContext(contextName string) error {
host := fmt.Sprintf("ssh://%s", contextName)
if err := createContext(contextName, host); err != nil {
return err
}
logrus.Debugf("created the %s context", contextName)
log.Debugf("created the %s context", contextName)
return nil
}

View File

@ -6,7 +6,7 @@ import (
"github.com/containers/image/docker"
"github.com/containers/image/types"
"github.com/docker/distribution/reference"
"github.com/distribution/reference"
)
// GetRegistryTags retrieves all tags of an image from a container registry.

View File

@ -2,15 +2,17 @@ package client
import (
"context"
"fmt"
"time"
"coopcloud.tech/abra/pkg/log"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
)
func GetVolumes(cl *client.Client, ctx context.Context, server string, fs filters.Args) ([]*volume.Volume, error) {
volumeListOptions := volume.ListOptions{fs}
volumeListOKBody, err := cl.VolumeList(ctx, volumeListOptions)
volumeListOKBody, err := cl.VolumeList(ctx, volume.ListOptions{Filters: fs})
volumeList := volumeListOKBody.Volumes
if err != nil {
return volumeList, err
@ -29,13 +31,32 @@ func GetVolumeNames(volumes []*volume.Volume) []string {
return volumeNames
}
func RemoveVolumes(cl *client.Client, ctx context.Context, server string, volumeNames []string, force bool) error {
func RemoveVolumes(cl *client.Client, ctx context.Context, volumeNames []string, force bool, retries int) error {
for _, volName := range volumeNames {
err := cl.VolumeRemove(ctx, volName, force)
err := retryFunc(5, func() error {
return cl.VolumeRemove(context.Background(), volName, force)
})
if err != nil {
return err
return fmt.Errorf("volume %s: %s", volName, err)
}
}
return nil
}
// retryFunc retries the given function for the given retries. After the nth
// retry it waits (n + 1)^2 seconds before the next retry (starting with n=0).
// It returns an error if the function still failed after the last retry.
func retryFunc(retries int, fn func() error) error {
for i := 0; i < retries; i++ {
err := fn()
if err == nil {
return nil
}
if i+1 < retries {
sleep := time.Duration(i+1) * time.Duration(i+1)
log.Infof("%s: waiting %d seconds before next retry", err, sleep)
time.Sleep(sleep * time.Second)
}
}
return fmt.Errorf("%d retries failed", retries)
}

View File

@ -1,4 +1,4 @@
package app
package client
import (
"fmt"

View File

@ -1,158 +0,0 @@
package compose
import (
"fmt"
"io/ioutil"
"path"
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
)
// UpdateTag updates an image tag in-place on file system local compose files.
func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
composeFiles, err := filepath.Glob(pattern)
if err != nil {
return false, err
}
logrus.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Image == "" {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
logrus.Debugf("unable to parse %s, skipping", img)
continue
}
composeImage := formatter.StripTagMeta(reference.Path(img))
logrus.Debugf("parsed %s from %s", composeTag, service.Image)
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1)
logrus.Debugf("updating %s to %s in %s", old, new, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
return false, err
}
}
}
}
return false, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
func UpdateLabel(pattern, serviceName, label, recipeName string) error {
composeFiles, err := filepath.Glob(pattern)
if err != nil {
return err
}
logrus.Debugf("considering %s config(s) for label update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil {
return err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return err
}
serviceExists := false
var service composetypes.ServiceConfig
for _, s := range compose.Services {
if s.Name == serviceName {
service = s
serviceExists = true
}
}
if !serviceExists {
continue
}
discovered := false
for oldLabel, value := range service.Deploy.Labels {
if strings.HasPrefix(oldLabel, "coop-cloud") && strings.Contains(oldLabel, "version") {
discovered = true
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return err
}
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
replacedBytes := strings.Replace(string(bytes), old, label, -1)
if old == label {
logrus.Warnf("%s is already set, nothing to do?", label)
return nil
}
logrus.Debugf("updating %s to %s in %s", old, label, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
return err
}
logrus.Infof("synced label %s to service %s", label, serviceName)
}
}
if !discovered {
logrus.Warn("no existing label found, automagic insertion not supported yet")
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile)
}
}
return nil
}

111
pkg/config/abra.go Normal file
View File

@ -0,0 +1,111 @@
package config
import (
"os"
"path"
"path/filepath"
"coopcloud.tech/abra/pkg/log"
"gopkg.in/yaml.v3"
)
// LoadAbraConfig returns the abra configuration. It tries to find a abra
// configuration file (see findAbraConfig for lookup logic). When no
// configuration was found it returns the default config.
func LoadAbraConfig() Abra {
wd, _ := os.Getwd()
configFile := findAbraConfig(wd)
if configFile == "" {
log.Debugf("no config file found")
return Abra{}
}
data, err := os.ReadFile(configFile)
if err != nil {
// Do nothing, when an error occurs
log.Debugf("error reading config file: %s", err)
return Abra{}
}
config := Abra{}
err = yaml.Unmarshal(data, &config)
if err != nil {
// Do nothing, when an error occurs
log.Debugf("error loading config file: %s", err)
return Abra{}
}
log.Debugf("config file loaded from: %s", configFile)
config.configPath = filepath.Dir(configFile)
return config
}
// findAbraConfig recursively looks for a abra.y(a)ml file in the given directory.
// When the file was not found it calls the function again with the parent
// directory until the home directory is hit. When no abra config was found it
// returns an empty string.
func findAbraConfig(dir string) string {
dir, err := filepath.Abs(dir)
if err != nil {
return ""
}
if dir == os.ExpandEnv("$HOME") || dir == "/" {
return ""
}
p := path.Join(dir, "abra.yaml")
if _, err := os.Stat(p); err == nil {
return p
}
p = path.Join(dir, "abra.yml")
if _, err := os.Stat(p); err == nil {
return p
}
return findAbraConfig(filepath.Dir(dir))
}
// Abra defines the configuration file for abra.
type Abra struct {
configPath string
AbraDir string `yaml:"abraDir"`
}
// GetAbraDir returns the abra dir. It has the following logic:
// 1. check if $ABRA_DIR is set
// 2. check if abraDir was set in a config file
// 3. use $HOME/.abra when above two options failed
func (a Abra) GetAbraDir() string {
if dir, exists := os.LookupEnv("ABRA_DIR"); exists && dir != "" {
log.Debug("read abra dir from $ABRA_DIR")
return dir
}
if a.AbraDir != "" {
log.Debug("read abra dir from config file")
if path.IsAbs(a.AbraDir) {
return a.AbraDir
}
// Make the path absolute
return path.Join(a.configPath, a.AbraDir)
}
log.Debug("using default abra dir")
return os.ExpandEnv("$HOME/.abra")
}
func (a Abra) GetServersDir() string { return path.Join(a.GetAbraDir(), "servers") }
func (a Abra) GetRecipesDir() string { return path.Join(a.GetAbraDir(), "recipes") }
func (a Abra) GetVendorDir() string { return path.Join(a.GetAbraDir(), "vendor") }
func (a Abra) GetBackupDir() string { return path.Join(a.GetAbraDir(), "backups") }
func (a Abra) GetCatalogueDir() string { return path.Join(a.GetAbraDir(), "catalogue") }
var config = LoadAbraConfig()
var (
ABRA_DIR = config.GetAbraDir()
SERVERS_DIR = config.GetServersDir()
RECIPES_DIR = config.GetRecipesDir()
VENDOR_DIR = config.GetVendorDir()
BACKUP_DIR = config.GetBackupDir()
CATALOGUE_DIR = config.GetCatalogueDir()
RECIPES_JSON = path.Join(config.GetCatalogueDir(), "recipes.json")
REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
CHAOS_DEFAULT = "false"
)

133
pkg/config/abra_test.go Normal file
View File

@ -0,0 +1,133 @@
package config
import (
"log"
"os"
"path/filepath"
"testing"
)
func TestFindAbraConfig(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
tests := []struct {
Dir string
Config string
}{
{
Dir: "testdata/abraconfig1",
Config: filepath.Join(wd, "testdata/abraconfig1/abra.yaml"),
},
{
Dir: "testdata/abraconfig1/subdir",
Config: filepath.Join(wd, "testdata/abraconfig1/abra.yaml"),
},
{
Dir: "testdata/abraconfig2",
Config: filepath.Join(wd, "testdata/abraconfig2/abra.yml"),
},
{
Dir: "testdata/abraconfig2/subdir",
Config: filepath.Join(wd, "testdata/abraconfig2/abra.yml"),
},
{
Dir: "testdata",
Config: "",
},
}
for _, tc := range tests {
t.Run(tc.Dir, func(t *testing.T) {
config := findAbraConfig(tc.Dir)
if config != tc.Config {
t.Errorf("\nwant: %s\ngot: %s", tc.Config, config)
}
})
}
}
func TestLoadAbraConfigGetAbraDir(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
t.Setenv("ABRA_DIR", "")
t.Run("default", func(t *testing.T) {
cfg := LoadAbraConfig()
wantAbraDir := os.ExpandEnv("$HOME/.abra")
if cfg.GetAbraDir() != wantAbraDir {
t.Errorf("\nwant: %s\ngot: %s", wantAbraDir, cfg.GetAbraDir())
}
})
t.Run("from config file", func(t *testing.T) {
t.Cleanup(func() { os.Chdir(wd) })
err = os.Chdir(filepath.Join(wd, "testdata/abraconfig1"))
if err != nil {
log.Fatal(err)
}
cfg := LoadAbraConfig()
wantAbraDir := filepath.Join(wd, "testdata/abraconfig1/foobar")
if cfg.GetAbraDir() != wantAbraDir {
t.Errorf("\nwant: %s\ngot: %s", wantAbraDir, cfg.GetAbraDir())
}
})
t.Run("default when config file is empty", func(t *testing.T) {
t.Cleanup(func() { os.Chdir(wd) })
err := os.Chdir(filepath.Join(wd, "testdata/abraconfig2"))
if err != nil {
log.Fatal(err)
}
cfg := LoadAbraConfig()
wantAbraDir := os.ExpandEnv("$HOME/.abra")
if cfg.GetAbraDir() != wantAbraDir {
t.Errorf("\nwant: %s\ngot: %s", wantAbraDir, cfg.GetAbraDir())
}
})
t.Run("from env variable", func(t *testing.T) {
t.Setenv("ABRA_DIR", "foo")
cfg := LoadAbraConfig()
wantAbraDir := "foo"
if cfg.GetAbraDir() != wantAbraDir {
t.Errorf("\nwant: %s\ngot: %s", wantAbraDir, cfg.GetAbraDir())
}
})
}
func TestLoadAbraConfigServersDir(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
t.Setenv("ABRA_DIR", "")
t.Run("default", func(t *testing.T) {
cfg := LoadAbraConfig()
wantServersDir := os.ExpandEnv("$HOME/.abra/servers")
if cfg.GetServersDir() != wantServersDir {
t.Errorf("\nwant: %s\ngot: %s", wantServersDir, cfg.GetServersDir())
}
})
t.Run("from config file", func(t *testing.T) {
t.Cleanup(func() { os.Chdir(wd) })
err = os.Chdir(filepath.Join(wd, "testdata/abraconfig1"))
if err != nil {
log.Fatal(err)
}
cfg := LoadAbraConfig()
log.Println(cfg)
wantServersDir := filepath.Join(wd, "testdata/abraconfig1/foobar/servers")
if cfg.GetServersDir() != wantServersDir {
t.Errorf("\nwant: %s\ngot: %s", wantServersDir, cfg.GetServersDir())
}
})
}

View File

@ -1,627 +0,0 @@
package config
import (
"fmt"
"io/ioutil"
"os"
"path"
"strconv"
"strings"
"github.com/schollz/progressbar/v3"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/upstream/convert"
loader "coopcloud.tech/abra/pkg/upstream/stack"
stack "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus"
)
// Type aliases to make code hints easier to understand
// AppEnv is a map of the values in an apps env config
type AppEnv = map[string]string
// AppModifiers is a map of modifiers in an apps env config
type AppModifiers = map[string]map[string]string
// AppName is AppName
type AppName = string
// AppFile represents app env files on disk without reading the contents
type AppFile struct {
Path string
Server string
}
// AppFiles is a slice of appfiles
type AppFiles map[AppName]AppFile
// App reprents an app with its env file read into memory
type App struct {
Name AppName
Recipe string
Domain string
Env AppEnv
Server string
Path string
}
// See documentation of config.StackName
func (a App) StackName() string {
if _, exists := a.Env["STACK_NAME"]; exists {
return a.Env["STACK_NAME"]
}
stackName := StackName(a.Name)
a.Env["STACK_NAME"] = stackName
return stackName
}
// StackName gets whatever the docker safe (uses the right delimiting
// character, e.g. "_") stack name is for the app. In general, you don't want
// to use this to show anything to end-users, you want use a.Name instead.
func StackName(appName string) string {
stackName := SanitiseAppName(appName)
if len(stackName) > 45 {
logrus.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:45])
stackName = stackName[:45]
}
return stackName
}
// Filters retrieves app filters for querying the container runtime. By default
// it filters on all services in the app. It is also possible to pass an
// otional list of service names, which get filtered instead.
//
// Due to upstream issues, filtering works different depending on what you're
// querying. So, for example, secrets don't work with regex! The caller needs
// to implement their own validation that the right secrets are matched. In
// order to handle these cases, we provide the `appendServiceNames` /
// `exactMatch` modifiers.
func (a App) Filters(appendServiceNames, exactMatch bool, services ...string) (filters.Args, error) {
filters := filters.NewArgs()
if len(services) > 0 {
for _, serviceName := range services {
filters.Add("name", ServiceFilter(a.StackName(), serviceName, exactMatch))
}
return filters, nil
}
// When not appending the service name, just add one filter for the whole
// stack.
if !appendServiceNames {
f := fmt.Sprintf("%s", a.StackName())
if exactMatch {
f = fmt.Sprintf("^%s", f)
}
filters.Add("name", f)
return filters, nil
}
composeFiles, err := GetComposeFiles(a.Recipe, a.Env)
if err != nil {
return filters, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(a.Recipe, opts, a.Env)
if err != nil {
return filters, err
}
for _, service := range compose.Services {
f := ServiceFilter(a.StackName(), service.Name, exactMatch)
filters.Add("name", f)
}
return filters, nil
}
// ServiceFilter creates a filter string for filtering a service in the docker
// container runtime. When exact match is true, it uses regex to match the
// string exactly.
func ServiceFilter(stack, service string, exact bool) string {
if exact {
return fmt.Sprintf("^%s_%s", stack, service)
}
return fmt.Sprintf("%s_%s", stack, service)
}
// ByServer sort a slice of Apps
type ByServer []App
func (a ByServer) Len() int { return len(a) }
func (a ByServer) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServer) Less(i, j int) bool {
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByServerAndRecipe sort a slice of Apps
type ByServerAndRecipe []App
func (a ByServerAndRecipe) Len() int { return len(a) }
func (a ByServerAndRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServerAndRecipe) Less(i, j int) bool {
if a[i].Server == a[j].Server {
return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe)
}
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByRecipe sort a slice of Apps
type ByRecipe []App
func (a ByRecipe) Len() int { return len(a) }
func (a ByRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByRecipe) Less(i, j int) bool {
return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe)
}
// ByName sort a slice of Apps
type ByName []App
func (a ByName) Len() int { return len(a) }
func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByName) Less(i, j int) bool {
return strings.ToLower(a[i].Name) < strings.ToLower(a[j].Name)
}
func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
env, err := ReadEnv(appFile.Path)
if err != nil {
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error())
}
logrus.Debugf("read env %s from %s", env, appFile.Path)
app, err := NewApp(env, name, appFile)
if err != nil {
return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error())
}
return app, nil
}
// NewApp creates new App object
func NewApp(env AppEnv, name string, appFile AppFile) (App, error) {
domain := env["DOMAIN"]
recipe, exists := env["RECIPE"]
if !exists {
recipe, exists = env["TYPE"]
if !exists {
return App{}, fmt.Errorf("%s is missing the TYPE env var?", name)
}
}
return App{
Name: name,
Domain: domain,
Recipe: recipe,
Env: env,
Server: appFile.Server,
Path: appFile.Path,
}, nil
}
// LoadAppFiles gets all app files for a given set of servers or all servers.
func LoadAppFiles(servers ...string) (AppFiles, error) {
appFiles := make(AppFiles)
if len(servers) == 1 {
if servers[0] == "" {
// Empty servers flag, one string will always be passed
var err error
servers, err = GetAllFoldersInDirectory(SERVERS_DIR)
if err != nil {
return appFiles, err
}
}
}
logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", "))
for _, server := range servers {
serverDir := path.Join(SERVERS_DIR, server)
files, err := GetAllFilesInDirectory(serverDir)
if err != nil {
return appFiles, fmt.Errorf("server %s doesn't exist? Run \"abra server ls\" to check", server)
}
for _, file := range files {
appName := strings.TrimSuffix(file.Name(), ".env")
appFilePath := path.Join(SERVERS_DIR, server, file.Name())
appFiles[appName] = AppFile{
Path: appFilePath,
Server: server,
}
}
}
return appFiles, nil
}
// GetApp loads an apps settings, reading it from file, in preparation to use
// it. It should only be used when ready to use the env file to keep IO
// operations down.
func GetApp(apps AppFiles, name AppName) (App, error) {
appFile, exists := apps[name]
if !exists {
return App{}, fmt.Errorf("cannot find app with name %s", name)
}
app, err := ReadAppEnvFile(appFile, name)
if err != nil {
return App{}, err
}
return app, nil
}
// GetApps returns a slice of Apps with their env files read from a given
// slice of AppFiles.
func GetApps(appFiles AppFiles, recipeFilter string) ([]App, error) {
var apps []App
for name := range appFiles {
app, err := GetApp(appFiles, name)
if err != nil {
return nil, err
}
if recipeFilter != "" {
if app.Recipe == recipeFilter {
apps = append(apps, app)
}
} else {
apps = append(apps, app)
}
}
return apps, nil
}
// GetAppServiceNames retrieves a list of app service names.
func GetAppServiceNames(appName string) ([]string, error) {
var serviceNames []string
appFiles, err := LoadAppFiles("")
if err != nil {
return serviceNames, err
}
app, err := GetApp(appFiles, appName)
if err != nil {
return serviceNames, err
}
composeFiles, err := GetComposeFiles(app.Recipe, app.Env)
if err != nil {
return serviceNames, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(app.Recipe, opts, app.Env)
if err != nil {
return serviceNames, err
}
for _, service := range compose.Services {
serviceNames = append(serviceNames, service.Name)
}
return serviceNames, nil
}
// GetAppNames retrieves a list of app names.
func GetAppNames() ([]string, error) {
var appNames []string
appFiles, err := LoadAppFiles("")
if err != nil {
return appNames, err
}
apps, err := GetApps(appFiles, "")
if err != nil {
return appNames, err
}
for _, app := range apps {
appNames = append(appNames, app.Name)
}
return appNames, nil
}
// TemplateAppEnvSample copies the example env file for the app into the users
// env files.
func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample")
envSample, err := ioutil.ReadFile(envSamplePath)
if err != nil {
return err
}
appEnvPath := path.Join(ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) {
return fmt.Errorf("%s already exists?", appEnvPath)
}
err = ioutil.WriteFile(appEnvPath, envSample, 0o664)
if err != nil {
return err
}
read, err := ioutil.ReadFile(appEnvPath)
if err != nil {
return err
}
newContents := strings.Replace(string(read), recipeName+".example.com", domain, -1)
err = ioutil.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return err
}
logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath)
return nil
}
// SanitiseAppName makes a app name usable with Docker by replacing illegal
// characters.
func SanitiseAppName(name string) string {
return strings.ReplaceAll(name, ".", "_")
}
// GetAppStatuses queries servers to check the deployment status of given apps.
func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]string, error) {
statuses := make(map[string]map[string]string)
servers := make(map[string]struct{})
for _, app := range apps {
if _, ok := servers[app.Server]; !ok {
servers[app.Server] = struct{}{}
}
}
var bar *progressbar.ProgressBar
if !MachineReadable {
bar = formatter.CreateProgressbar(len(servers), "querying remote servers...")
}
ch := make(chan stack.StackStatus, len(servers))
for server := range servers {
cl, err := client.New(server)
if err != nil {
return statuses, err
}
go func(s string) {
ch <- stack.GetAllDeployedServices(cl, s)
if !MachineReadable {
bar.Add(1)
}
}(server)
}
for range servers {
status := <-ch
if status.Err != nil {
return statuses, status.Err
}
for _, service := range status.Services {
result := make(map[string]string)
name := service.Spec.Labels[convert.LabelNamespace]
if _, ok := statuses[name]; !ok {
result["status"] = "deployed"
}
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", name)
chaos, ok := service.Spec.Labels[labelKey]
if ok {
result["chaos"] = chaos
}
labelKey = fmt.Sprintf("coop-cloud.%s.chaos-version", name)
if chaosVersion, ok := service.Spec.Labels[labelKey]; ok {
result["chaosVersion"] = chaosVersion
}
labelKey = fmt.Sprintf("coop-cloud.%s.autoupdate", name)
if autoUpdate, ok := service.Spec.Labels[labelKey]; ok {
result["autoUpdate"] = autoUpdate
} else {
result["autoUpdate"] = "false"
}
labelKey = fmt.Sprintf("coop-cloud.%s.version", name)
if version, ok := service.Spec.Labels[labelKey]; ok {
result["version"] = version
} else {
continue
}
statuses[name] = result
}
}
logrus.Debugf("retrieved app statuses: %s", statuses)
return statuses, nil
}
// ensurePathExists ensures that a path exists.
func ensurePathExists(path string) error {
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
return err
}
return nil
}
// GetComposeFiles gets the list of compose files for an app (or recipe if you
// don't already have an app) which should be merged into a composetypes.Config
// while respecting the COMPOSE_FILE env var.
func GetComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
var composeFiles []string
composeFileEnvVar, ok := appEnv["COMPOSE_FILE"]
if !ok {
path := fmt.Sprintf("%s/%s/compose.yml", RECIPES_DIR, recipe)
if err := ensurePathExists(path); err != nil {
return composeFiles, err
}
logrus.Debugf("no COMPOSE_FILE detected, loading default: %s", path)
composeFiles = append(composeFiles, path)
return composeFiles, nil
}
if !strings.Contains(composeFileEnvVar, ":") {
path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, composeFileEnvVar)
if err := ensurePathExists(path); err != nil {
return composeFiles, err
}
logrus.Debugf("COMPOSE_FILE detected, loading %s", path)
composeFiles = append(composeFiles, path)
return composeFiles, nil
}
numComposeFiles := strings.Count(composeFileEnvVar, ":") + 1
envVars := strings.SplitN(composeFileEnvVar, ":", numComposeFiles)
if len(envVars) != numComposeFiles {
return composeFiles, fmt.Errorf("COMPOSE_FILE (=\"%s\") parsing failed?", composeFileEnvVar)
}
for _, file := range envVars {
path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, file)
if err := ensurePathExists(path); err != nil {
return composeFiles, err
}
composeFiles = append(composeFiles, path)
}
logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", "))
logrus.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), recipe)
return composeFiles, nil
}
// GetAppComposeConfig retrieves a compose specification for a recipe. This
// specification is the result of a merge of all the compose.**.yml files in
// the recipe repository.
func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*composetypes.Config, error) {
compose, err := loader.LoadComposefile(opts, appEnv)
if err != nil {
return &composetypes.Config{}, err
}
logrus.Debugf("retrieved %s for %s", compose.Filename, recipe)
return compose, nil
}
// ExposeAllEnv exposes all env variables to the app container
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("Add the following environment to the app service config of %s:", stackName)
for k, v := range appEnv {
_, exists := service.Environment[k]
if !exists {
value := v
service.Environment[k] = &value
logrus.Debugf("Add Key: %s Value: %s to %s", k, value, stackName)
}
}
}
}
}
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
// to signal which recipe is connected to the deployed app
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
service.Deploy.Labels[labelKey] = recipe
}
}
}
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
// to signal if the app is deployed in chaos mode
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set label 'coop-cloud.%s.chaos' to %v for %s", stackName, chaos, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos", stackName)
service.Deploy.Labels[labelKey] = strconv.FormatBool(chaos)
}
}
}
// SetChaosVersionLabel adds the label 'coop-cloud.${STACK_NAME}.chaos-version=$(GIT_COMMIT)' to the app container
func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosVersion string) {
for _, service := range compose.Services {
if service.Name == "app" {
logrus.Debugf("set label 'coop-cloud.%s.chaos-version' to %v for %s", stackName, chaosVersion, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.chaos-version", stackName)
service.Deploy.Labels[labelKey] = chaosVersion
}
}
}
// SetUpdateLabel adds env ENABLE_AUTO_UPDATE as label to enable/disable the
// auto update process for this app. The default if this variable is not set is to disable
// the auto update process.
func SetUpdateLabel(compose *composetypes.Config, stackName string, appEnv AppEnv) {
for _, service := range compose.Services {
if service.Name == "app" {
enable_auto_update, exists := appEnv["ENABLE_AUTO_UPDATE"]
if !exists {
enable_auto_update = "false"
}
logrus.Debugf("set label 'coop-cloud.%s.autoupdate' to %s for %s", stackName, enable_auto_update, stackName)
labelKey := fmt.Sprintf("coop-cloud.%s.autoupdate", stackName)
service.Deploy.Labels[labelKey] = enable_auto_update
}
}
}
// GetLabel reads docker labels in the format of "coop-cloud.${STACK_NAME}.${LABEL}" from the local compose files
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
for _, service := range compose.Services {
if service.Name == "app" {
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
logrus.Debugf("get label '%s'", labelKey)
if labelValue, ok := service.Deploy.Labels[labelKey]; ok {
return labelValue
}
}
}
logrus.Debugf("no %s label found for %s", label, stackName)
return ""
}
// GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value
func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) {
timeout := 50 // Default Timeout
var err error = nil
if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" {
logrus.Debugf("timeout label: %s", timeoutLabel)
timeout, err = strconv.Atoi(timeoutLabel)
}
return timeout, err
}

View File

@ -1,45 +1,21 @@
package config
import (
"bufio"
"fmt"
"io/fs"
"io/ioutil"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"git.coopcloud.tech/coop-cloud/godotenv"
"github.com/sirupsen/logrus"
"coopcloud.tech/abra/pkg/log"
)
// getBaseDir retrieves the Abra base directory.
func getBaseDir() string {
home := os.ExpandEnv("$HOME/.abra")
if customAbraDir, exists := os.LookupEnv("ABRA_DIR"); exists && customAbraDir != "" {
home = customAbraDir
}
return home
}
const MAX_SANITISED_APP_NAME_LENGTH = 45
const MAX_DOCKER_SECRET_LENGTH = 64
var ABRA_DIR = getBaseDir()
var SERVERS_DIR = path.Join(ABRA_DIR, "servers")
var RECIPES_DIR = path.Join(ABRA_DIR, "recipes")
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor")
var BACKUP_DIR = path.Join(ABRA_DIR, "backups")
var CATALOGUE_DIR = path.Join(ABRA_DIR, "catalogue")
var RECIPES_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
var CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
var SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
// envVarModifiers is a list of env var modifier strings. These are added to
// env vars as comments and modify their processing by Abra, e.g. determining
// how long secrets should be.
var envVarModifiers = []string{"length"}
var BackupbotLabel = "coop-cloud.backupbot.enabled"
// GetServers retrieves all servers.
func GetServers() ([]string, error) {
@ -50,39 +26,11 @@ func GetServers() ([]string, error) {
return servers, err
}
logrus.Debugf("retrieved %v servers: %s", len(servers), servers)
log.Debugf("retrieved %v servers: %s", len(servers), servers)
return servers, nil
}
// ReadEnv loads an app envivornment into a map.
func ReadEnv(filePath string) (AppEnv, error) {
var envVars AppEnv
envVars, _, err := godotenv.Read(filePath)
if err != nil {
return nil, err
}
logrus.Debugf("read %s from %s", envVars, filePath)
return envVars, nil
}
// ReadEnv loads an app envivornment and their modifiers in two different maps.
func ReadEnvWithModifiers(filePath string) (AppEnv, AppModifiers, error) {
var envVars AppEnv
envVars, mods, err := godotenv.Read(filePath)
if err != nil {
return nil, mods, err
}
logrus.Debugf("read %s from %s", envVars, filePath)
return envVars, mods, nil
}
// ReadServerNames retrieves all server names.
func ReadServerNames() ([]string, error) {
serverNames, err := GetAllFoldersInDirectory(SERVERS_DIR)
@ -91,7 +39,7 @@ func ReadServerNames() ([]string, error) {
return nil, err
}
logrus.Debugf("read %s from %s", strings.Join(serverNames, ","), SERVERS_DIR)
log.Debugf("read %s from %s", strings.Join(serverNames, ","), SERVERS_DIR)
return serverNames, nil
}
@ -115,7 +63,7 @@ func GetAllFilesInDirectory(directory string) ([]fs.FileInfo, error) {
realPath, err := filepath.EvalSymlinks(filePath)
if err != nil {
logrus.Warningf("broken symlink in your abra config folders: %s", filePath)
log.Warnf("broken symlink in your abra config folders: %s", filePath)
} else {
realFile, err := os.Stat(realPath)
if err != nil {
@ -148,7 +96,7 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
filePath := path.Join(directory, file.Name())
realDir, err := filepath.EvalSymlinks(filePath)
if err != nil {
logrus.Warningf("broken symlink in your abra config folders: %s", filePath)
log.Warnf("broken symlink in your abra config folders: %s", filePath)
} else if stat, err := os.Stat(realDir); err == nil && stat.IsDir() {
// path is a directory
folders = append(folders, file.Name())
@ -158,119 +106,3 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
return folders, nil
}
// ReadAbraShEnvVars reads env vars from an abra.sh recipe file.
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
envVars := make(map[string]string)
file, err := os.Open(abraSh)
if err != nil {
if os.IsNotExist(err) {
return envVars, nil
}
return envVars, err
}
defer file.Close()
exportRegex, err := regexp.Compile(`^export\s+(\w+=\w+)`)
if err != nil {
return envVars, err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
txt := scanner.Text()
if exportRegex.MatchString(txt) {
splitVals := strings.Split(txt, "export ")
envVarDef := splitVals[len(splitVals)-1]
keyVal := strings.Split(envVarDef, "=")
if len(keyVal) != 2 {
return envVars, fmt.Errorf("couldn't parse %s", txt)
}
envVars[keyVal[0]] = keyVal[1]
}
}
if len(envVars) > 0 {
logrus.Debugf("read %s from %s", envVars, abraSh)
} else {
logrus.Debugf("read 0 env var exports from %s", abraSh)
}
return envVars, nil
}
type EnvVar struct {
Name string
Present bool
}
func CheckEnv(app App) ([]EnvVar, error) {
var envVars []EnvVar
envSamplePath := path.Join(RECIPES_DIR, app.Recipe, ".env.sample")
if _, err := os.Stat(envSamplePath); err != nil {
if os.IsNotExist(err) {
return envVars, fmt.Errorf("%s does not exist?", envSamplePath)
}
return envVars, err
}
envSample, err := ReadEnv(envSamplePath)
if err != nil {
return envVars, err
}
var keys []string
for key := range envSample {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, ok := app.Env[key]; ok {
envVars = append(envVars, EnvVar{Name: key, Present: true})
} else {
envVars = append(envVars, EnvVar{Name: key, Present: false})
}
}
return envVars, nil
}
// ReadAbraShCmdNames reads the names of commands.
func ReadAbraShCmdNames(abraSh string) ([]string, error) {
var cmdNames []string
file, err := os.Open(abraSh)
if err != nil {
if os.IsNotExist(err) {
return cmdNames, nil
}
return cmdNames, err
}
defer file.Close()
cmdNameRegex, err := regexp.Compile(`(\w+)(\(\).*\{)`)
if err != nil {
return cmdNames, err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
matches := cmdNameRegex.FindStringSubmatch(line)
if len(matches) > 0 {
cmdNames = append(cmdNames, matches[1])
}
}
if len(cmdNames) > 0 {
logrus.Debugf("read %s from %s", strings.Join(cmdNames, " "), abraSh)
} else {
logrus.Debugf("read 0 command names from %s", abraSh)
}
return cmdNames, nil
}

View File

@ -0,0 +1 @@
abraDir: foobar

View File

View File

View File

View File

@ -6,18 +6,19 @@ import (
"strings"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// GetContainer retrieves a container. If noInput is false and the retrievd
// count of containers does not match 1, then a prompt is presented to let the
// user choose. A count of 0 is handled gracefully.
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, noInput bool) (types.Container, error) {
containerOpts := types.ContainerListOptions{Filters: filters}
containerOpts := containerTypes.ListOptions{Filters: filters}
containers, err := cl.ContainerList(c, containerOpts)
if err != nil {
return types.Container{}, err
@ -28,7 +29,7 @@ func GetContainer(c context.Context, cl *client.Client, filters filters.Args, no
return types.Container{}, fmt.Errorf("no containers matching the %v filter found?", filter)
}
if len(containers) != 1 {
if len(containers) > 1 {
var containersRaw []string
for _, container := range containers {
containerName := strings.Join(container.Names, " ")
@ -42,7 +43,7 @@ func GetContainer(c context.Context, cl *client.Client, filters filters.Args, no
return types.Container{}, err
}
logrus.Warnf("ambiguous container list received, prompting for input")
log.Warnf("ambiguous container list received, prompting for input")
var response string
prompt := &survey.Select{
@ -63,7 +64,7 @@ func GetContainer(c context.Context, cl *client.Client, filters filters.Args, no
}
}
logrus.Panic("failed to match chosen container")
log.Fatal("failed to match chosen container")
}
return containers[0], nil

View File

@ -9,7 +9,7 @@ import (
func EnsureIPv4(domainName string) (string, error) {
ipv4, err := net.ResolveIPAddr("ip4", domainName)
if err != nil {
return "", err
return "", fmt.Errorf("unable to resolve ipv4 address for %s, %s", domainName, err)
}
// NOTE(d1): e.g. when there is only an ipv6 record available

97
pkg/envfile/envfile.go Normal file
View File

@ -0,0 +1,97 @@
package envfile
import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
"coopcloud.tech/abra/pkg/log"
"git.coopcloud.tech/coop-cloud/godotenv"
)
// envVarModifiers is a list of env var modifier strings. These are added to
// env vars as comments and modify their processing by Abra, e.g. determining
// how long secrets should be.
var envVarModifiers = []string{"length"}
// AppEnv is a map of the values in an apps env config
type AppEnv = map[string]string
// AppModifiers is a map of modifiers in an apps env config
type AppModifiers = map[string]map[string]string
// ReadEnv loads an app envivornment into a map.
func ReadEnv(filePath string) (AppEnv, error) {
var envVars AppEnv
envVars, _, err := godotenv.Read(filePath)
if err != nil {
return nil, err
}
log.Debugf("read %s from %s", envVars, filePath)
return envVars, nil
}
// ReadEnv loads an app envivornment and their modifiers in two different maps.
func ReadEnvWithModifiers(filePath string) (AppEnv, AppModifiers, error) {
var envVars AppEnv
envVars, mods, err := godotenv.Read(filePath)
if err != nil {
return nil, mods, err
}
log.Debugf("read %s from %s", envVars, filePath)
return envVars, mods, nil
}
// ReadAbraShEnvVars reads env vars from an abra.sh recipe file.
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
envVars := make(map[string]string)
file, err := os.Open(abraSh)
if err != nil {
if os.IsNotExist(err) {
return envVars, nil
}
return envVars, err
}
defer file.Close()
exportRegex, err := regexp.Compile(`^export\s+(\w+=\w+)`)
if err != nil {
return envVars, err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
txt := scanner.Text()
if exportRegex.MatchString(txt) {
splitVals := strings.Split(txt, "export ")
envVarDef := splitVals[len(splitVals)-1]
keyVal := strings.Split(envVarDef, "=")
if len(keyVal) != 2 {
return envVars, fmt.Errorf("couldn't parse %s", txt)
}
envVars[keyVal[0]] = keyVal[1]
}
}
if len(envVars) > 0 {
log.Debugf("read %s from %s", envVars, abraSh)
} else {
log.Debugf("read 0 env var exports from %s", abraSh)
}
return envVars, nil
}
type EnvVar struct {
Name string
Present bool
}

View File

@ -1,69 +1,31 @@
package config_test
package envfile_test
import (
"fmt"
"os"
"path"
"reflect"
"slices"
"strings"
"testing"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/recipe"
testPkg "coopcloud.tech/abra/pkg/test"
"github.com/stretchr/testify/assert"
)
var (
TestFolder = os.ExpandEnv("$PWD/../../tests/resources/test_folder")
ValidAbraConf = os.ExpandEnv("$PWD/../../tests/resources/valid_abra_config")
)
// make sure these are in alphabetical order
var (
TFolders = []string{"folder1", "folder2"}
TFiles = []string{"bar.env", "foo.env"}
)
var (
AppName = "ecloud"
ServerName = "evil.corp"
)
var ExpectedAppEnv = config.AppEnv{
"DOMAIN": "ecloud.evil.corp",
"RECIPE": "ecloud",
}
var ExpectedApp = config.App{
Name: AppName,
Recipe: ExpectedAppEnv["RECIPE"],
Domain: ExpectedAppEnv["DOMAIN"],
Env: ExpectedAppEnv,
Path: ExpectedAppFile.Path,
Server: ExpectedAppFile.Server,
}
var ExpectedAppFile = config.AppFile{
Path: path.Join(ValidAbraConf, "servers", ServerName, AppName+".env"),
Server: ServerName,
}
var ExpectedAppFiles = map[string]config.AppFile{
AppName: ExpectedAppFile,
}
func TestGetAllFoldersInDirectory(t *testing.T) {
folders, err := config.GetAllFoldersInDirectory(TestFolder)
folders, err := config.GetAllFoldersInDirectory(testPkg.TestFolder)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(folders, TFolders) {
t.Fatalf("did not get expected folders. Expected: (%s), Got: (%s)", strings.Join(TFolders, ","), strings.Join(folders, ","))
if !reflect.DeepEqual(folders, testPkg.TFolders) {
t.Fatalf("did not get expected folders. Expected: (%s), Got: (%s)", strings.Join(testPkg.TFolders, ","), strings.Join(folders, ","))
}
}
func TestGetAllFilesInDirectory(t *testing.T) {
files, err := config.GetAllFilesInDirectory(TestFolder)
files, err := config.GetAllFilesInDirectory(testPkg.TestFolder)
if err != nil {
t.Fatal(err)
}
@ -71,36 +33,29 @@ func TestGetAllFilesInDirectory(t *testing.T) {
for _, file := range files {
fileNames = append(fileNames, file.Name())
}
if !reflect.DeepEqual(fileNames, TFiles) {
t.Fatalf("did not get expected files. Expected: (%s), Got: (%s)", strings.Join(TFiles, ","), strings.Join(fileNames, ","))
if !reflect.DeepEqual(fileNames, testPkg.TFiles) {
t.Fatalf("did not get expected files. Expected: (%s), Got: (%s)", strings.Join(testPkg.TFiles, ","), strings.Join(fileNames, ","))
}
}
func TestReadEnv(t *testing.T) {
env, err := config.ReadEnv(ExpectedAppFile.Path)
env, err := envfile.ReadEnv(testPkg.ExpectedAppFile.Path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(env, ExpectedAppEnv) {
t.Fatalf(
"did not get expected application settings. Expected: DOMAIN=%s RECIPE=%s; Got: DOMAIN=%s RECIPE=%s",
ExpectedAppEnv["DOMAIN"],
ExpectedAppEnv["RECIPE"],
env["DOMAIN"],
env["RECIPE"],
)
if !reflect.DeepEqual(env, testPkg.ExpectedAppEnv) {
t.Fatal("did not get expected application settings")
}
}
func TestReadAbraShEnvVars(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, r.Name, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
abraShEnv, err := envfile.ReadAbraShEnvVars(r.AbraShPath)
if err != nil {
t.Fatal(err)
}
@ -123,14 +78,13 @@ func TestReadAbraShEnvVars(t *testing.T) {
}
func TestReadAbraShCmdNames(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, r.Name, "abra.sh")
cmdNames, err := config.ReadAbraShCmdNames(abraShPath)
cmdNames, err := appPkg.ReadAbraShCmdNames(r.AbraShPath)
if err != nil {
t.Fatal(err)
}
@ -142,34 +96,33 @@ func TestReadAbraShCmdNames(t *testing.T) {
expectedCmdNames := []string{"test_cmd", "test_cmd_args"}
for _, cmdName := range expectedCmdNames {
if !slices.Contains(cmdNames, cmdName) {
t.Fatalf("%s should have been found in %s", cmdName, abraShPath)
t.Fatalf("%s should have been found in %s", cmdName, r.AbraShPath)
}
}
}
func TestCheckEnv(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
envSamplePath := path.Join(config.RECIPES_DIR, r.Name, ".env.sample")
envSample, err := config.ReadEnv(envSamplePath)
envSample, err := r.SampleEnv()
if err != nil {
t.Fatal(err)
}
app := config.App{
app := appPkg.App{
Name: "test-app",
Recipe: r.Name,
Recipe: recipe.Get(r.Name),
Domain: "example.com",
Env: envSample,
Path: "example.com.env",
Server: "example.com",
}
envVars, err := config.CheckEnv(app)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
t.Fatal(err)
}
@ -182,30 +135,29 @@ func TestCheckEnv(t *testing.T) {
}
func TestCheckEnvError(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
envSamplePath := path.Join(config.RECIPES_DIR, r.Name, ".env.sample")
envSample, err := config.ReadEnv(envSamplePath)
envSample, err := r.SampleEnv()
if err != nil {
t.Fatal(err)
}
delete(envSample, "DOMAIN")
app := config.App{
app := appPkg.App{
Name: "test-app",
Recipe: r.Name,
Recipe: recipe.Get(r.Name),
Domain: "example.com",
Env: envSample,
Path: "example.com.env",
Server: "example.com",
}
envVars, err := config.CheckEnv(app)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
t.Fatal(err)
}
@ -218,14 +170,13 @@ func TestCheckEnvError(t *testing.T) {
}
func TestEnvVarCommentsRemoved(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
envSamplePath := path.Join(config.RECIPES_DIR, r.Name, ".env.sample")
envSample, err := config.ReadEnv(envSamplePath)
envSample, err := r.SampleEnv()
if err != nil {
t.Fatal(err)
}
@ -250,14 +201,13 @@ func TestEnvVarCommentsRemoved(t *testing.T) {
}
func TestEnvVarModifiersIncluded(t *testing.T) {
offline := true
r, err := recipe.Get("abra-test-recipe", offline)
r := recipe.Get("abra-test-recipe")
err := r.EnsureExists()
if err != nil {
t.Fatal(err)
}
envSamplePath := path.Join(config.RECIPES_DIR, r.Name, ".env.sample")
envSample, modifiers, err := config.ReadEnvWithModifiers(envSamplePath)
envSample, modifiers, err := envfile.ReadEnvWithModifiers(r.SampleEnvPath)
if err != nil {
t.Fatal(err)
}
@ -273,3 +223,21 @@ func TestEnvVarModifiersIncluded(t *testing.T) {
}
}
}
func TestNoOverwriteNonVersionEnvVars(t *testing.T) {
app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
if err := app.WriteRecipeVersion("1.3.12", true); err != nil {
t.Fatal(err)
}
app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
assert.NotEqual(t, app.Env["SMTP_AUTHTYPE"], "login:1.3.12")
}

View File

@ -1,18 +1,26 @@
package formatter
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/docker/go-units"
// "github.com/olekukonko/tablewriter"
"coopcloud.tech/abra/pkg/jsontable"
"golang.org/x/term"
"coopcloud.tech/abra/pkg/log"
"github.com/schollz/progressbar/v3"
"github.com/sirupsen/logrus"
)
var BoldStyle = lipgloss.NewStyle().
Bold(true).
Underline(true)
func ShortenID(str string) string {
return str[:12]
}
@ -34,11 +42,67 @@ func HumanDuration(timestamp int64) string {
}
// CreateTable prepares a table layout for output.
func CreateTable(columns []string) *jsontable.JSONTable {
table := jsontable.NewJSONTable(os.Stdout)
table.SetAutoWrapText(false)
table.SetHeader(columns)
return table
func CreateTable() (*table.Table, error) {
table := table.New().
Border(lipgloss.ThickBorder()).
BorderStyle(
lipgloss.NewStyle().
Foreground(lipgloss.Color("63")),
)
if isAbraCI, ok := os.LookupEnv("ABRA_CI"); ok && isAbraCI == "1" {
// NOTE(d1): no width limits for CI testing since we test against outputs
log.Debug("detected ABRA_CI=1")
return table, nil
}
width, _, err := term.GetSize(0)
if err != nil {
return nil, err
}
if width-10 < 79 {
// NOTE(d1): maintain standard minimum width
table.Width(79)
} else {
// NOTE(d1): tests show that this produces stable border drawing
table.Width(width - 10)
}
return table, nil
}
// ToJSON converts a lipgloss.Table to JSON representation. It's not a robust
// implementation and mainly caters for our current use case which is basically
// a bunch of strings. See https://github.com/charmbracelet/lipgloss/issues/335
// for the real thing (hopefully).
func ToJSON(headers []string, rows [][]string) (string, error) {
var buff bytes.Buffer
buff.Write([]byte("["))
for idx, row := range rows {
payload := make(map[string]string)
for idx, header := range headers {
payload[strings.ToLower(header)] = row[idx]
}
serialized, err := json.Marshal(payload)
if err != nil {
return "", err
}
buff.Write(serialized)
if idx < (len(rows) - 1) {
buff.Write([]byte(","))
}
}
buff.Write([]byte("]"))
return buff.String(), nil
}
// CreateProgressbar generates a progress bar
@ -66,7 +130,7 @@ func StripTagMeta(image string) string {
}
if originalImage != image {
logrus.Debugf("stripped %s to %s for parsing", originalImage, image)
log.Debugf("stripped %s to %s for parsing", originalImage, image)
}
return image

View File

@ -1,8 +1,8 @@
package git
import (
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// Add adds a file to the git index.
@ -18,7 +18,7 @@ func Add(repoPath, path string, dryRun bool) error {
}
if dryRun {
logrus.Debugf("dry run: adding %s", path)
log.Debugf("dry run: adding %s", path)
} else {
worktree.Add(path)
}

View File

@ -3,9 +3,9 @@ package git
import (
"fmt"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
// Check if a branch exists in a repo. Use this and not repository.Branch(),
@ -90,11 +90,11 @@ func CheckoutDefaultBranch(repo *git.Repository, repoPath string) (plumbing.Refe
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out %s in %s", branch, repoPath)
log.Debugf("failed to check out %s in %s", branch, repoPath)
return branch, err
}
logrus.Debugf("successfully checked out %v in %s", branch, repoPath)
log.Debugf("successfully checked out %v in %s", branch, repoPath)
return branch, nil
}

View File

@ -6,15 +6,15 @@ import (
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
// Clone runs a git clone which accounts for different default branches.
func Clone(dir, url string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, attempting to git clone from %s", dir, url)
log.Debugf("%s does not exist, attempting to git clone from %s", dir, url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url,
@ -23,7 +23,7 @@ func Clone(dir, url string) error {
SingleBranch: true,
})
if err != nil {
logrus.Debugf("cloning %s default branch failed, attempting from main branch", url)
log.Debugf("cloning %s default branch failed, attempting from main branch", url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url,
@ -41,9 +41,9 @@ func Clone(dir, url string) error {
}
}
logrus.Debugf("%s has been git cloned successfully", dir)
log.Debugf("%s has been git cloned successfully", dir)
} else {
logrus.Debugf("%s already exists", dir)
log.Debugf("%s already exists", dir)
}
return nil

View File

@ -3,8 +3,8 @@ package git
import (
"fmt"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
)
// Commit runs a git commit
@ -38,9 +38,9 @@ func Commit(repoPath, commitMessage string, dryRun bool) error {
if err != nil {
return err
}
logrus.Debug("git changes commited")
log.Debug("git changes commited")
} else {
logrus.Debug("dry run: no changes commited")
log.Debug("dry run: no changes commited")
}
return nil

View File

@ -4,7 +4,7 @@ import (
"fmt"
"os/exec"
"github.com/sirupsen/logrus"
"coopcloud.tech/abra/pkg/log"
)
// getGitDiffArgs builds the `git diff` invocation args. It removes the usage
@ -26,7 +26,7 @@ func getGitDiffArgs(repoPath string) []string {
// skips if it cannot find the command on the system.
func DiffUnstaged(path string) error {
if _, err := exec.LookPath("git"); err != nil {
logrus.Warnf("unable to locate git command, cannot output diff")
log.Warnf("unable to locate git command, cannot output diff")
return nil
}

View File

@ -1,37 +1,43 @@
package git
import (
"fmt"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
gitPkg "github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus"
"github.com/go-git/go-git/v5/plumbing/object"
)
// 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)
func Init(repoPath string, commit bool, gitName, gitEmail string) error {
if _, err := git.PlainInit(repoPath, false); err != nil {
return fmt.Errorf("git init: %s", err)
}
logrus.Debugf("initialised new git repo in %s", repoPath)
log.Debugf("initialised new git repo in %s", repoPath)
if commit {
commitRepo, err := git.PlainOpen(repoPath)
if err != nil {
logrus.Fatal(err)
return fmt.Errorf("git open: %s", err)
}
commitWorktree, err := commitRepo.Worktree()
if err != nil {
logrus.Fatal(err)
return fmt.Errorf("git worktree: %s", err)
}
if err := commitWorktree.AddWithOptions(&git.AddOptions{All: true}); err != nil {
return err
return fmt.Errorf("git add: %s", err)
}
if _, err = commitWorktree.Commit("init", &git.CommitOptions{}); err != nil {
return err
var author *object.Signature
if gitName != "" && gitEmail != "" {
author = &object.Signature{Name: gitName, Email: gitEmail}
}
logrus.Debugf("init committed all files for new git repo in %s", repoPath)
if _, err = commitWorktree.Commit("init", &git.CommitOptions{Author: author}); err != nil {
return fmt.Errorf("git commit: %s", err)
}
log.Debugf("init committed all files for new git repo in %s", repoPath)
}
return nil

View File

@ -1,15 +1,15 @@
package git
import (
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus"
)
// Push pushes the latest changes & optionally tags to the default remote
func Push(repoDir string, remote string, tags bool, dryRun bool) error {
if dryRun {
logrus.Debugf("dry run: no git changes pushed in %s", repoDir)
log.Debugf("dry run: no git changes pushed in %s", repoDir)
return nil
}
@ -27,7 +27,7 @@ func Push(repoDir string, remote string, tags bool, dryRun bool) error {
return err
}
logrus.Debugf("git changes pushed")
log.Debugf("git changes pushed")
if tags {
opts.RefSpecs = append(opts.RefSpecs, config.RefSpec("+refs/tags/*:refs/tags/*"))
@ -36,7 +36,7 @@ func Push(repoDir string, remote string, tags bool, dryRun bool) error {
return err
}
logrus.Debugf("git tags pushed")
log.Debugf("git tags pushed")
}
return nil

View File

@ -4,35 +4,15 @@ import (
"io/ioutil"
"os"
"os/user"
"path"
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
gitConfigPkg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/sirupsen/logrus"
)
// GetRecipeHead retrieves latest HEAD metadata.
func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
recipeDir := path.Join(config.RECIPES_DIR, 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(repoPath string) (bool, error) {
repo, err := git.PlainOpen(repoPath)
@ -60,9 +40,9 @@ func IsClean(repoPath string) (bool, error) {
}
if status.String() != "" {
logrus.Debugf("discovered git status in %s: %s", repoPath, status.String())
log.Debugf("discovered git status in %s: %s", repoPath, status.String())
} else {
logrus.Debugf("discovered clean git status in %s", repoPath)
log.Debugf("discovered clean git status in %s", repoPath)
}
return status.IsClean(), nil
@ -98,7 +78,7 @@ func parseGitConfig() (*gitConfigPkg.Config, error) {
globalGitConfig := filepath.Join(usr.HomeDir, ".gitconfig")
if _, err := os.Stat(globalGitConfig); err != nil {
if os.IsNotExist(err) {
logrus.Debugf("no %s exists, not reading any global gitignore config", globalGitConfig)
log.Debugf("no %s exists, not reading any global gitignore config", globalGitConfig)
return cfg, nil
}
return cfg, err
@ -140,7 +120,7 @@ func parseExcludesFile(excludesfile string) ([]gitignore.Pattern, error) {
if _, err := os.Stat(excludesfile); err != nil {
if os.IsNotExist(err) {
logrus.Debugf("no %s exists, skipping reading gitignore paths", excludesfile)
log.Debugf("no %s exists, skipping reading gitignore paths", excludesfile)
return ps, nil
}
return ps, err
@ -159,7 +139,7 @@ func parseExcludesFile(excludesfile string) ([]gitignore.Pattern, error) {
}
}
logrus.Debugf("read global ignore paths: %s", strings.Join(pathsRaw, " "))
log.Debugf("read global ignore paths: %s", strings.Join(pathsRaw, " "))
return ps, nil
}

View File

@ -3,15 +3,15 @@ package git
import (
"strings"
"coopcloud.tech/abra/pkg/log"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus"
)
// CreateRemote creates a new git remote in a repository
func CreateRemote(repo *git.Repository, name, url string, dryRun bool) error {
if dryRun {
logrus.Debugf("dry run: remote %s (%s) not created", name, url)
log.Debugf("dry run: remote %s (%s) not created", name, url)
return nil
}

View File

@ -1,211 +0,0 @@
package jsontable
import (
"fmt"
"io"
"strings"
"github.com/olekukonko/tablewriter"
)
// A quick-and-dirty proxy/emulator of tablewriter to enable more easy machine readable output
// - Does not strictly support types, just quoted or unquoted values
// - Does not support nested values.
// If a datalabel is set with SetDataLabel(true, "..."), that will be used as the key for teh data of the table,
// otherwise if the caption is set with SetCaption(true, "..."), the data label will be set to the default of
// "rows", otherwise the table will output as a JSON list.
//
// Proxys all actions through to the tablewriter except addrow and addbatch, which it does at render time
//
type JSONTable struct {
out io.Writer
colsize int
rows [][]string
keys []string
quoted []bool // hack to do output typing, quoted vs. unquoted
hasDataLabel bool
dataLabel string
hasCaption bool
caption string // the actual caption
hasCaptionLabel bool
captionLabel string // the key in the dictionary for the caption
tbl *tablewriter.Table
}
func writeChar(w io.Writer, c byte) {
w.Write([]byte{c})
}
func NewJSONTable(writer io.Writer) *JSONTable {
t := &JSONTable{
out: writer,
colsize: 0,
rows: [][]string{},
keys: []string{},
quoted: []bool{},
hasDataLabel: false,
dataLabel: "rows",
hasCaption: false,
caption: "",
hasCaptionLabel: false,
captionLabel: "caption",
tbl: tablewriter.NewWriter(writer),
}
return t
}
func (t *JSONTable) NumLines() int {
// JSON only but reflects a shared state.
return len(t.rows)
}
func (t *JSONTable) SetHeader(keys []string) {
// Set the keys value which will assign each column to the keys.
// Note that we'll ignore values that are beyond the length of the keys list
t.colsize = len(keys)
t.keys = []string{}
for _, k := range keys {
t.keys = append(t.keys, k)
t.quoted = append(t.quoted, true)
}
t.tbl.SetHeader(keys)
}
func (t *JSONTable) SetColumnQuoting(quoting []bool) {
// Specify which columns are quoted or unquoted in output
// JSON only
for i := 0; i < t.colsize; i++ {
t.quoted[i] = quoting[i]
}
}
func (t *JSONTable) Append(row []string) {
// We'll just append whatever to the rows list. If they fix the keys after appending rows, it'll work as
// expected.
// We should detect if the row is narrower than the key list tho.
// JSON only (but we use the rows later when rendering a regular table)
t.rows = append(t.rows, row)
}
func (t *JSONTable) Render() {
// Load the table with rows and render.
// Proxy only
for _, row := range t.rows {
t.tbl.Append(row)
}
t.tbl.Render()
}
func (t *JSONTable) _JSONRenderInner() {
// JSON only
// Render the list of dictionaries to the writer.
//// inner render loop
writeChar(t.out, '[')
for rowidx, row := range t.rows {
if rowidx != 0 {
writeChar(t.out, ',')
}
writeChar(t.out, '{')
for keyidx, key := range t.keys {
key := strings.ToLower(key)
key = strings.ReplaceAll(key, " ", "-")
value := "nil"
if keyidx < len(row) {
value = row[keyidx]
}
if keyidx != 0 {
writeChar(t.out, ',')
}
if t.quoted[keyidx] {
fmt.Fprintf(t.out, "\"%s\":\"%s\"", key, value)
} else {
fmt.Fprintf(t.out, "\"%s\":%s", key, value)
}
}
writeChar(t.out, '}')
}
writeChar(t.out, ']')
}
func (t *JSONTable) JSONRender() {
// write JSON table to output
// JSON only
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '{')
if t.hasCaption {
fmt.Fprintf(t.out, "\"%s\":\"%s\",", t.captionLabel, t.caption)
}
fmt.Fprintf(t.out, "\"%s\":", t.dataLabel)
}
// write list
t._JSONRenderInner()
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '}')
}
}
func (t *JSONTable) SetCaption(caption bool, captionText ...string) {
t.hasCaption = caption
if len(captionText) == 1 {
t.caption = captionText[0]
}
t.tbl.SetCaption(caption, captionText...)
}
func (t *JSONTable) SetCaptionLabel(captionLabel bool, captionLabelText ...string) {
// JSON only
t.hasCaptionLabel = captionLabel
if len(captionLabelText) == 1 {
t.captionLabel = captionLabelText[0]
}
}
func (t *JSONTable) SetDataLabel(dataLabel bool, dataLabelText ...string) {
// JSON only
t.hasDataLabel = dataLabel
if len(dataLabelText) == 1 {
t.dataLabel = dataLabelText[0]
}
}
func (t *JSONTable) AppendBulk(rows [][]string) {
// JSON only but reflects shared state
for _, row := range rows {
t.Append(row)
}
}
// Stuff we should implement but we just proxy for now.
func (t *JSONTable) SetAutoMergeCellsByColumnIndex(cols []int) {
// FIXME
t.tbl.SetAutoMergeCellsByColumnIndex(cols)
}
// Stuff we should implement but we just proxy for now.
func (t *JSONTable) SetAlignment(align int) {
// FIXME
t.tbl.SetAlignment(align)
}
func (t *JSONTable) SetAutoMergeCells(auto bool) {
// FIXME
t.tbl.SetAutoMergeCells(auto)
}
// Stub functions
func (t *JSONTable) SetAutoWrapText(auto bool) {
t.tbl.SetAutoWrapText(auto)
return
}

View File

@ -1,83 +0,0 @@
package jsontable
import (
"testing"
"bytes"
"encoding/json"
"github.com/olekukonko/tablewriter"
)
var TestLine = []string{"1", "2"}
var TestGroup = [][]string{{"1", "2", "3"}, {"a", "teohunteohu", "c", "d"}, {"☺", "☹"}}
var TestKeys = []string{"key0", "key1", "key2"}
// test creation
func TestNewTable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
if tbl.NumLines() != 0 {
t.Fatalf("Something went weird when making table (should have 0 lines)")
}
}
// test adding things
func TestTableAdd(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.Append(TestLine)
if tbl.NumLines() != 1 {
t.Fatalf("Appending a line does not result in a length of 1.")
}
tbl.AppendBulk(TestGroup)
numlines := tbl.NumLines()
if numlines != (len(TestGroup) + 1) {
t.Fatalf("Appending two lines does not result in a length of 4 (length is %d).", numlines)
}
}
// test JSON output is parsable
func TestJsonParsable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.JSONRender()
var son []map[string]interface{}
err := json.Unmarshal(b.Bytes(), &son)
if err != nil {
t.Fatalf("Did not produce parsable JSON: %s", err.Error())
}
}
// test identical commands to a tablewriter and jsontable produce the same rendered output
func TestTableWriter(t *testing.T) {
var bjson bytes.Buffer
var btable bytes.Buffer
tbl := NewJSONTable(&bjson)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.Render()
wtbl := tablewriter.NewWriter(&btable)
wtbl.AppendBulk(TestGroup)
wtbl.SetHeader(TestKeys)
wtbl.Render()
if bytes.Compare(bjson.Bytes(), btable.Bytes()) != 0 {
t.Fatalf("JSON table and TableWriter produce non-identical outputs.\n%s\n%s", bjson.Bytes(), btable.Bytes())
}
}
/// FIXME test different output formats when captions etc. are added

View File

@ -6,14 +6,13 @@ import (
"os"
"path"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/docker/distribution/reference"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
var Warn = "warn"
@ -46,10 +45,10 @@ func (l LintRule) Skip(recipe recipe.Recipe) bool {
if l.SkipCondition != nil {
ok, err := l.SkipCondition(recipe)
if err != nil {
logrus.Debugf("%s: skip condition: %s", l.Ref, err)
log.Debugf("%s: skip condition: %s", l.Ref, err)
}
if ok {
logrus.Debugf("skipping %s based on skip condition", l.Ref)
log.Debugf("skipping %s based on skip condition", l.Ref)
return true
}
}
@ -115,6 +114,13 @@ var LintRules = map[string][]LintRule{
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
Function: LintHasRecipeRepo,
},
{
Ref: "R015",
Level: "warn",
Description: "long secret names",
HowToResolve: "reduce length of secret names to 12 chars",
Function: LintSecretLengths,
},
},
"error": {
{
@ -167,7 +173,7 @@ var LintRules = map[string][]LintRule{
// used in code paths such as "app deploy" to avoid nasty surprises but not for
// the typical linting commands, which do handle other levels.
func LintForErrors(recipe recipe.Recipe) error {
logrus.Debugf("linting for critical errors in %s configs", recipe.Name)
log.Debugf("linting for critical errors in %s configs", recipe.Name)
for level := range LintRules {
if level != "error" {
@ -189,22 +195,25 @@ func LintForErrors(recipe recipe.Recipe) error {
}
}
logrus.Debugf("linting successful, %s is well configured", recipe.Name)
log.Debugf("linting successful, %s is well configured", recipe.Name)
return nil
}
func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
if recipe.Config.Version == "3.8" {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
if config.Version == "3.8" {
return true, nil
}
return true, nil
}
func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) {
envSample := fmt.Sprintf("%s/%s/.env.sample", config.RECIPES_DIR, recipe.Name)
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
func LintEnvConfigPresent(r recipe.Recipe) (bool, error) {
if _, err := os.Stat(r.SampleEnvPath); !os.IsNotExist(err) {
return true, nil
}
@ -212,7 +221,11 @@ func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) {
}
func LintAppService(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
if service.Name == "app" {
return true, nil
}
@ -225,11 +238,10 @@ func LintAppService(recipe recipe.Recipe) (bool, error) {
// confirms that there is no "DOMAIN=..." in the .env.sample configuration of
// the recipe. This typically means that no domain is required to deploy and
// therefore no matching traefik deploy label will be present.
func LintTraefikEnabledSkipCondition(recipe recipe.Recipe) (bool, error) {
envSamplePath := path.Join(config.RECIPES_DIR, recipe.Name, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath)
func LintTraefikEnabledSkipCondition(r recipe.Recipe) (bool, error) {
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, fmt.Errorf("Unable to discover .env.sample for %s", recipe.Name)
return false, fmt.Errorf("Unable to discover .env.sample for %s", r.Name)
}
if _, ok := sampleEnv["DOMAIN"]; !ok {
@ -240,7 +252,11 @@ func LintTraefikEnabledSkipCondition(recipe recipe.Recipe) (bool, error) {
}
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
for label := range service.Deploy.Labels {
if label == "traefik.enable" {
if service.Deploy.Labels[label] == "true" {
@ -254,7 +270,11 @@ func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
}
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
if service.HealthCheck == nil {
return false, nil
}
@ -264,7 +284,11 @@ func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
}
func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
@ -278,7 +302,11 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
}
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
@ -301,7 +329,11 @@ func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
}
func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
@ -324,7 +356,11 @@ func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
}
func LintImagePresent(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
if service.Image == "" {
return false, nil
}
@ -335,12 +371,12 @@ func LintImagePresent(recipe recipe.Recipe) (bool, error) {
func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
catl, err := recipePkg.ReadRecipeCatalogue(false)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
if err != nil {
logrus.Fatal(err)
log.Fatal(err)
}
if len(versions) == 0 {
@ -351,7 +387,7 @@ func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
}
func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
features, category, err := recipe.GetRecipeFeaturesAndCategory(r.Name)
features, category, err := recipe.GetRecipeFeaturesAndCategory(r)
if err != nil {
return false, err
}
@ -372,9 +408,13 @@ func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
}
func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
if len(service.Configs) > 0 {
abraSh := path.Join(config.RECIPES_DIR, recipe.Name, "abra.sh")
abraSh := path.Join(recipe.Dir, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
return false, err
@ -387,9 +427,7 @@ func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
}
func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe.Name)
res, err := http.Get(url)
res, err := http.Get(recipe.GitURL)
if err != nil {
return false, err
}
@ -401,17 +439,29 @@ func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
return true, nil
}
func LintValidTags(recipe recipe.Recipe) (bool, error) {
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir)
func LintSecretLengths(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, fmt.Errorf("unable to open %s: %s", recipeDir, err)
return false, err
}
for name := range config.Secrets {
if len(name) > 12 {
return false, fmt.Errorf("secret %s is longer than 12 characters", name)
}
}
return true, nil
}
func LintValidTags(recipe recipe.Recipe) (bool, error) {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return false, fmt.Errorf("unable to open %s: %s", recipe.Dir, err)
}
iter, err := repo.Tags()
if err != nil {
logrus.Fatalf("unable to list local tags for %s", recipe.Name)
log.Fatalf("unable to list local tags for %s", recipe.Name)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {

75
pkg/log/log.go Normal file
View File

@ -0,0 +1,75 @@
// Package log defines the core logging functionality for Abra.
package log
import (
"os"
"strings"
"github.com/charmbracelet/lipgloss"
charmLog "github.com/charmbracelet/log"
)
// Logger is the central logging interface.
var Logger = charmLog.NewWithOptions(os.Stdout, charmLog.Options{
ReportCaller: false,
ReportTimestamp: false,
})
var Fatal = Logger.Fatal
var Fatalf = Logger.Fatalf
var Debug = Logger.Debug
var Debugf = Logger.Debugf
var Info = Logger.Info
var Infof = Logger.Infof
var Warn = Logger.Warn
var Warnf = Logger.Warnf
var Error = Logger.Error
var Errorf = Logger.Errorf
var SetLevel = Logger.SetLevel
var DebugLevel = charmLog.DebugLevel
var SetOutput = charmLog.SetOutput
var SetReportCaller = charmLog.SetReportCaller
func Styles() *charmLog.Styles {
styles := charmLog.DefaultStyles()
styles.Levels = map[charmLog.Level]lipgloss.Style{
charmLog.DebugLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(DebugLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("63")).
Foreground(lipgloss.Color("15")),
charmLog.InfoLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.InfoLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("86")).
Foreground(lipgloss.Color("16")),
charmLog.WarnLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.WarnLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("192")).
Foreground(lipgloss.Color("16")),
charmLog.ErrorLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.ErrorLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("204")).
Foreground(lipgloss.Color("15")),
charmLog.FatalLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.FatalLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("134")).
Foreground(lipgloss.Color("15")),
}
return styles
}

252
pkg/recipe/compose.go Normal file
View File

@ -0,0 +1,252 @@
package recipe
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/distribution/reference"
composetypes "github.com/docker/cli/cli/compose/types"
)
// GetComposeFiles gets the list of compose files for an app (or recipe if you
// don't already have an app) which should be merged into a composetypes.Config
// while respecting the COMPOSE_FILE env var.
func (r Recipe) GetComposeFiles(appEnv map[string]string) ([]string, error) {
composeFileEnvVar, ok := appEnv["COMPOSE_FILE"]
if !ok {
if err := ensurePathExists(r.ComposePath); err != nil {
return []string{}, err
}
log.Debugf("no COMPOSE_FILE detected, loading default: %s", r.ComposePath)
return []string{r.ComposePath}, nil
}
if !strings.Contains(composeFileEnvVar, ":") {
path := fmt.Sprintf("%s/%s", r.Dir, composeFileEnvVar)
if err := ensurePathExists(path); err != nil {
return []string{}, err
}
log.Debugf("COMPOSE_FILE detected, loading %s", path)
return []string{path}, nil
}
var composeFiles []string
numComposeFiles := strings.Count(composeFileEnvVar, ":") + 1
envVars := strings.SplitN(composeFileEnvVar, ":", numComposeFiles)
if len(envVars) != numComposeFiles {
return composeFiles, fmt.Errorf("COMPOSE_FILE (=\"%s\") parsing failed?", composeFileEnvVar)
}
for _, file := range envVars {
path := fmt.Sprintf("%s/%s", r.Dir, file)
if err := ensurePathExists(path); err != nil {
return composeFiles, err
}
composeFiles = append(composeFiles, path)
}
log.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", "))
log.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), r.Name)
return composeFiles, nil
}
func (r Recipe) GetComposeConfig(env map[string]string) (*composetypes.Config, error) {
pattern := fmt.Sprintf("%s/compose**yml", r.Dir)
composeFiles, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
if len(composeFiles) == 0 {
return nil, fmt.Errorf("%s is missing a compose.yml or compose.*.yml file?", r.Name)
}
if env == nil {
env, err = r.SampleEnv()
if err != nil {
return nil, err
}
}
opts := stack.Deploy{Composefiles: composeFiles}
config, err := loader.LoadComposefile(opts, env)
if err != nil {
return nil, err
}
return config, nil
}
// GetVersionLabelLocal retrieves the version label on the local recipe config
func (r Recipe) GetVersionLabelLocal() (string, error) {
var label string
config, err := r.GetComposeConfig(nil)
if err != nil {
return "", err
}
for _, service := range config.Services {
for label, value := range service.Deploy.Labels {
if strings.HasPrefix(label, "coop-cloud") && strings.Contains(label, "version") {
return value, nil
}
}
}
if label == "" {
return label, fmt.Errorf("%s has no version label? try running \"abra recipe sync %s\" first?", r.Name, r.Name)
}
return label, nil
}
// UpdateTag updates an image tag in-place on file system local compose files.
func (r Recipe) UpdateTag(image, tag string) (bool, error) {
fullPattern := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
composeFiles, err := filepath.Glob(fullPattern)
if err != nil {
return false, err
}
log.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Image == "" {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
log.Debugf("unable to parse %s, skipping", img)
continue
}
composeImage := formatter.StripTagMeta(reference.Path(img))
log.Debugf("parsed %s from %s", composeTag, service.Image)
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debugf("updating %s to %s in %s", old, new, compose.Filename)
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
}
}
}
return false, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
fullPattern := fmt.Sprintf("%s/%s", r.Dir, pattern)
composeFiles, err := filepath.Glob(fullPattern)
if err != nil {
return err
}
log.Debugf("considering %s config(s) for label update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return err
}
serviceExists := false
var service composetypes.ServiceConfig
for _, s := range compose.Services {
if s.Name == serviceName {
service = s
serviceExists = true
}
}
if !serviceExists {
continue
}
discovered := false
for oldLabel, value := range service.Deploy.Labels {
if strings.HasPrefix(oldLabel, "coop-cloud") && strings.Contains(oldLabel, "version") {
discovered = true
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return err
}
old := fmt.Sprintf("coop-cloud.${STACK_NAME}.version=%s", value)
replacedBytes := strings.Replace(string(bytes), old, label, -1)
if old == label {
log.Warnf("%s is already set, nothing to do?", label)
return nil
}
log.Debugf("updating %s to %s in %s", old, label, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return err
}
log.Infof("synced label %s to service %s", label, serviceName)
}
}
if !discovered {
log.Warn("no existing label found, automagic insertion not supported yet")
log.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile)
}
}
return nil
}

37
pkg/recipe/files.go Normal file
View File

@ -0,0 +1,37 @@
package recipe
import (
"fmt"
"os"
"path"
"coopcloud.tech/abra/pkg/envfile"
)
func (r Recipe) SampleEnv() (map[string]string, error) {
sampleEnv, err := envfile.ReadEnv(r.SampleEnvPath)
if err != nil {
return sampleEnv, fmt.Errorf("unable to discover .env.sample for %s", r.Name)
}
return sampleEnv, nil
}
// GetReleaseNotes prints release notes for the recipe version
func (r Recipe) GetReleaseNotes(version string) (string, error) {
if version == "" {
return "", nil
}
fpath := path.Join(r.Dir, "release", version)
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
releaseNotes, err := os.ReadFile(fpath)
if err != nil {
return "", err
}
withTitle := fmt.Sprintf("%s release notes:\n%s", version, string(releaseNotes))
return withTitle, nil
}
return "", nil
}

402
pkg/recipe/git.go Normal file
View File

@ -0,0 +1,402 @@
package recipe
import (
"fmt"
"os"
"strings"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/log"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// Ensure makes sure the recipe exists, is up to date and has the latest version checked out.
func (r Recipe) Ensure(chaos bool, offline bool) error {
if err := r.EnsureExists(); err != nil {
return err
}
if chaos {
return nil
}
if err := r.EnsureIsClean(); err != nil {
return err
}
if !offline {
if err := r.EnsureUpToDate(); err != nil {
log.Fatal(err)
}
}
if r.Version != "" {
log.Debugf("ensuring version %s", r.Version)
if _, err := r.EnsureVersion(r.Version); err != nil {
return err
}
return nil
}
if err := r.EnsureLatest(); err != nil {
return err
}
return nil
}
// EnsureExists ensures that the recipe is locally cloned
func (r Recipe) EnsureExists() error {
if _, err := os.Stat(r.Dir); os.IsNotExist(err) {
log.Debugf("%s does not exist, attemmpting to clone", r.Dir)
if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil {
return err
}
}
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return err
}
return nil
}
// EnsureVersion checks whether a specific version exists for a recipe.
func (r Recipe) EnsureVersion(version string) (bool, error) {
isChaosCommit := false
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return isChaosCommit, err
}
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return isChaosCommit, err
}
tags, err := repo.Tags()
if err != nil {
return isChaosCommit, err
}
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()
}
return nil
}); err != nil {
return isChaosCommit, err
}
joinedTags := strings.Join(parsedTags, ", ")
if joinedTags != "" {
log.Debugf("read %s as tags for recipe %s", joinedTags, r.Name)
}
var opts *git.CheckoutOptions
if tagRef.String() == "" {
log.Debugf("attempting to checkout '%s' as chaos commit", version)
hash, err := repo.ResolveRevision(plumbing.Revision(version))
if err != nil {
log.Fatalf("unable to resolve '%s': %s", version, err)
}
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
isChaosCommit = true
} else {
opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true}
}
worktree, err := repo.Worktree()
if err != nil {
return isChaosCommit, nil
}
if err := worktree.Checkout(opts); err != nil {
return isChaosCommit, nil
}
log.Debugf("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir)
return isChaosCommit, nil
}
// EnsureIsClean makes sure that the recipe repository has no unstaged changes.
func (r Recipe) EnsureIsClean() error {
isClean, err := gitPkg.IsClean(r.Dir)
if err != nil {
return fmt.Errorf("unable to check git clean status in %s: %s", r.Dir, err)
}
if !isClean {
msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding"
return fmt.Errorf(msg, r.Name, r.Dir)
}
return nil
}
// EnsureLatest makes sure the latest commit is checked out for the local recipe repository
func (r Recipe) EnsureLatest() error {
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return err
}
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branch, err := gitPkg.GetDefaultBranch(repo, r.Dir)
if err != nil {
return err
}
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(branch),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
log.Debugf("failed to check out %s in %s", branch, r.Dir)
return err
}
return nil
}
// EnsureUpToDate ensures that the local repo is synced to the remote
func (r Recipe) EnsureUpToDate() error {
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return fmt.Errorf("unable to open %s: %s", r.Dir, err)
}
remotes, err := repo.Remotes()
if err != nil {
return fmt.Errorf("unable to read remotes in %s: %s", r.Dir, err)
}
if len(remotes) == 0 {
log.Debugf("cannot ensure %s is up-to-date, no git remotes configured", r.Name)
return nil
}
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("unable to open git work tree in %s: %s", r.Dir, err)
}
branch, err := gitPkg.CheckoutDefaultBranch(repo, r.Dir)
if err != nil {
return fmt.Errorf("unable to check out default branch in %s: %s", r.Dir, err)
}
fetchOpts := &git.FetchOptions{Tags: git.AllTags}
if err := repo.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return fmt.Errorf("unable to fetch tags in %s: %s", r.Dir, err)
}
}
opts := &git.PullOptions{
Force: true,
ReferenceName: branch,
SingleBranch: true,
}
if err := worktree.Pull(opts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return fmt.Errorf("unable to git pull in %s: %s", r.Dir, err)
}
}
log.Debugf("fetched latest git changes for %s", r.Name)
return nil
}
// ChaosVersion constructs a chaos mode recipe version.
func (r Recipe) ChaosVersion() (string, error) {
var version string
head, err := r.Head()
if err != nil {
return version, err
}
version = formatter.SmallSHA(head.String())
isClean, err := gitPkg.IsClean(r.Dir)
if err != nil {
return version, err
}
if !isClean {
version = fmt.Sprintf("%s + unstaged changes", version)
}
return version, nil
}
// Push pushes the latest changes to a SSH URL remote. You need to have your
// local SSH configuration for git.coopcloud.tech working for this to work
func (r Recipe) Push(dryRun bool) error {
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return err
}
if err := gitPkg.CreateRemote(repo, "origin-ssh", r.SSHURL, dryRun); err != nil {
return err
}
if err := gitPkg.Push(r.Dir, "origin-ssh", true, dryRun); err != nil {
return err
}
return nil
}
// Tags list the recipe tags
func (r Recipe) Tags() ([]string, error) {
var tags []string
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return tags, err
}
gitTags, err := repo.Tags()
if err != nil {
return tags, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tags = append(tags, strings.TrimPrefix(string(ref.Name()), "refs/tags/"))
return nil
}); err != nil {
return tags, err
}
log.Debugf("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name)
return tags, nil
}
// GetRecipeVersions retrieves all recipe versions.
func (r Recipe) GetRecipeVersions() (RecipeVersions, error) {
versions := RecipeVersions{}
log.Debugf("attempting to open git repository in %s", r.Dir)
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return versions, err
}
worktree, err := repo.Worktree()
if err != nil {
return versions, err
}
gitTags, err := repo.Tags()
if err != nil {
return versions, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
log.Debugf("processing %s for %s", tag, r.Name)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
log.Debugf("failed to check out %s in %s", tag, r.Dir)
return err
}
log.Debugf("successfully checked out %s in %s", ref.Name(), r.Dir)
config, err := r.GetComposeConfig(nil)
if err != nil {
return err
}
versionMeta := make(map[string]ServiceMeta)
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return err
}
path := reference.Path(img)
path = formatter.StripTagMeta(path)
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
log.Warnf("%s service is missing image tag?", path)
continue
}
versionMeta[service.Name] = ServiceMeta{
Image: path,
Tag: tag,
}
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil
}); err != nil {
return versions, err
}
_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
if err != nil {
return versions, err
}
sortRecipeVersions(versions)
log.Debugf("collected %s for %s", versions, r.Dir)
return versions, nil
}
// Head retrieves latest HEAD metadata.
func (r Recipe) Head() (*plumbing.Reference, error) {
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return nil, err
}
head, err := repo.Head()
if err != nil {
return nil, err
}
return head, nil
}

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