Compare commits

..

377 Commits

Author SHA1 Message Date
David Heinemeier Hansson
60a19f0b30 Bump version for 0.11.0 2023-04-12 11:45:33 +02:00
David Heinemeier Hansson
2d0a7e1b67 Merge pull request #208 from tannakartikey/add_labels_to_traefik
Labels can be added to Traefik
2023-04-12 11:35:28 +02:00
David Heinemeier Hansson
49df19fb0d Merge pull request #209 from ncreuschling/fix-roles-documentation
fix typo role to roles
2023-04-12 11:34:02 +02:00
David Heinemeier Hansson
cef8fddfb4 Merge pull request #210 from basecamp/role-aware-rollbacks
Make rollbacks role-aware
2023-04-12 11:33:45 +02:00
Kartikey Tanna
c59eb00dd0 Labels can be added to Traefik 2023-04-12 14:53:48 +05:30
Donal McBreen
43f7409de0 Make rollbacks role-aware
Rollbacks stopped working after https://github.com/mrsked/mrsk/pull/99.

We'll confirm that a container is available for the first role on the
primary host before attempting to rollback.
2023-04-12 09:59:39 +01:00
Nicolai Reuschling
448ea7719f fix typo role to roles 2023-04-12 10:53:10 +02:00
David Heinemeier Hansson
ab54dbdb8b Merge pull request #206 from tannakartikey/traefik_rule_docs
Explained the latest modifications of Traefik container labels
2023-04-11 14:18:31 +02:00
David Heinemeier Hansson
ac3771447a Merge pull request #203 from matharvard/main
Require net-ssh ~> 7.0 for SHA-2 support
2023-04-11 14:17:52 +02:00
David Heinemeier Hansson
daa0c9b5be Merge pull request #196 from handy-la/main
Configurable max_attempts for healthcheck
2023-04-11 14:17:17 +02:00
Kartikey Tanna
d3936363d0 Explained the latest modifications of Traefik container labels 2023-04-11 10:20:16 +05:30
Arturo Ojeda
cfc8fa0590 Remove .idea folder 2023-04-10 22:33:20 -06:00
Arturo Ojeda
161ebe4bc1 Updated README.md with new healthcheck.max_attempts option 2023-04-10 22:26:10 -06:00
Arturo Ojeda
514b2aa243 Fix test case: console output message was not updated to display the current/total attempts 2023-04-10 09:29:19 -06:00
David Heinemeier Hansson
18031bc552 Merge pull request #202 from basecamp/deploy-lock-acquisition
Improved deploy lock acquisition
2023-04-10 16:42:03 +02:00
Mat Harvard
d8c61004e4 Require net-ssh ~> 7.0 for SHA-2 support
Versions of net-ssh before 7.0 do not support the SHA-2 algorithm and result in mrsk not being able to connect to hosts using keys generated with it. net-ssh is also a dependency of sshkit, however, sshkit has a version requirement of >= 2.8.0 for net-ssh, so is not effective at ensuring mrsk has the version it needs to be the most compatible.
2023-04-10 07:29:07 -07:00
Donal McBreen
c4df440c79 Improved deploy lock acquisition
1. Don't raise lock error for non-lock issues during lock acquire
  (see https://github.com/mrsked/mrsk/pull/181)
2. If there is an error while the lock is held, don't release the lock
  and send a warning to stderr
2023-04-10 15:23:00 +01:00
David Heinemeier Hansson
fb1718ca6d Merge pull request #197 from tannakartikey/traefik_rules_with_destination
Traefik service name to be derived from role and destination
2023-04-10 15:11:07 +02:00
David Heinemeier Hansson
7d17a6c3b5 Excess CR 2023-04-10 15:10:08 +02:00
David Heinemeier Hansson
f4133de896 Merge pull request #176 from dilpreet92/enable_ssh_over_proxy_command
Enable ssh over proxy command
2023-04-10 14:41:45 +02:00
David Heinemeier Hansson
a9488e935d Style 2023-04-10 14:39:18 +02:00
David Heinemeier Hansson
ac61528dfc Merge pull request #189 from basecamp/traefik-image
Traefik image config for version pinning, upgrades, and custom images
2023-04-10 14:35:30 +02:00
David Heinemeier Hansson
0eb7a8d087 Merge branch 'main' into pr/176
* main:
  Simpler
  Make it explicit, focus on Ubuntu
  More explicit
  Not that --bundle is a Rails 7+ option
  Update README.md
  Update README.md
  Add github discussions link to readme
  Bump debug to fix missing deps in CI
  Only redact the non-sensitive bits of build args and env vars.
  improve code sample (traefik configuration)
2023-04-10 14:31:43 +02:00
David Heinemeier Hansson
7559f439e9 Merge pull request #195 from nickhammond/patch-1
Add github discussions link to readme
2023-04-10 14:28:59 +02:00
David Heinemeier Hansson
54a5b90d8f Simpler 2023-04-10 14:28:52 +02:00
David Heinemeier Hansson
a245adfad2 Merge pull request #200 from huksley/main
Add sample commands to bootstrap non-root ssh server
2023-04-10 14:27:13 +02:00
David Heinemeier Hansson
f386c3bdab Make it explicit, focus on Ubuntu 2023-04-10 14:26:49 +02:00
David Heinemeier Hansson
2a3e576182 More explicit 2023-04-10 14:24:51 +02:00
David Heinemeier Hansson
f3e3196ce5 Not that --bundle is a Rails 7+ option 2023-04-10 14:22:58 +02:00
Ruslan Gainutdinov
fca5b11682 Update README.md
Use docker.io on Ubuntu
2023-04-10 12:26:57 +03:00
Ruslan Gainutdinov
d09cddde8d Update README.md
Add sample commands to bootstrap non-root ssh server.
2023-04-10 12:23:06 +03:00
Arturo Ojeda
3969f56fa6 Improved: configurable max_attempts for healthcheck 2023-04-09 12:07:27 -06:00
Kartikey Tanna
c60cc92dfe Traefik service name to be derived from role and destination 2023-04-09 13:44:57 +05:30
Arturo Ojeda
cb3c5a53f4 Configurable max_attempts for healthcheck 2023-04-08 19:52:53 -06:00
Nick Hammond
ef04410d77 Add github discussions link to readme
I realize that there's a discussions link on github but I didn't realize mrsk actually utilized it until I saw it mentioned on Discord. I was thinking adding it to the readme would help push people there.
2023-04-08 13:33:31 -07:00
Jeremy Daer
bd8f13dd5e Traefik image config for version pinning, upgrades, and custom images
Accounts for the 2.9.10 security release and allows testing Traefik 3 betas.

* Use `image` to configure a specific Traefik Docker image.
* Default to `traefik:v2.9` to track future 2.9.x minor releases rather
  than tightly pinning to `v2.9.9`.
* Support images from the configured registry.

References #165
2023-04-07 14:15:25 -07:00
David Heinemeier Hansson
2146f6d0ec Merge pull request #182 from basecamp/sensitive-args
Only redact the non-sensitive bits of build args and env vars.
2023-04-06 16:19:41 +02:00
David Heinemeier Hansson
52d8c112d3 Merge branch 'main' into pr/182
* main:
  Bump debug to fix missing deps in CI
2023-04-06 16:18:22 +02:00
David Heinemeier Hansson
c9afd66222 Merge pull request #184 from basecamp/fix-ci 2023-04-06 13:21:26 +02:00
Jeremy Daer
36c458407f Bump debug to fix missing deps in CI 2023-04-05 12:00:15 -07:00
Jeremy Daer
c137b38c87 Only redact the non-sensitive bits of build args and env vars.
* `-e [REDACTED]` → `-e SOME_SECRET=[REDACTED]`
* Replaces `Utils.redact` with `Utils.sensitive` to clarify that we're
  indicating redactability, not actually performing redaction.
* Redacts from YAML output, including `mrsk config` (fixes #96)
2023-04-05 09:45:28 -07:00
David Heinemeier Hansson
f851d6528d Merge pull request #169 from ncreuschling/patch-1
improve code sample (traefik configuration)
2023-04-05 16:31:10 +02:00
Dilpreet Singh
12632aa7f9 Enable ssh over proxy command 2023-04-03 17:14:06 +05:30
Nicolai Reuschling
2f97bc488f improve code sample (traefik configuration)
fixed yaml format (code sample traefik configuration)
2023-03-31 11:50:43 +02:00
David Heinemeier Hansson
032266a76a Bump version for 0.10.1 2023-03-29 16:23:58 +02:00
David Heinemeier Hansson
33cc6c8bae Merge pull request #166 from calmyournerves/exit-code
Set proper exit code on failure
2023-03-29 16:21:54 +02:00
Samuel Sieg
5638ab8594 Set proper exit code on failure 2023-03-29 13:47:34 +02:00
David Heinemeier Hansson
60916cdac3 Bump version for 0.10.0 2023-03-28 18:05:46 +02:00
David Heinemeier Hansson
1f83b5f6be Fix failure to pass on class options to subcommands 2023-03-28 18:04:16 +02:00
David Heinemeier Hansson
070c6e8e75 Merge pull request #165 from basecamp/pin-traefik-version
Pin Traefik to v2.9.9
2023-03-28 16:27:49 +02:00
Kevin McConnell
2957388bf6 Pin Traefik to v2.9.9 2023-03-28 14:59:03 +01:00
David Heinemeier Hansson
7f178101f7 Merge pull request #164 from basecamp/accessory-hosts-or-roles
Run accessories on multiple hosts or roles
2023-03-28 14:31:24 +02:00
David Heinemeier Hansson
aed345466f Dropped "all" 2023-03-28 14:28:54 +02:00
Donal McBreen
c06585fef4 Daemon/host/role accessories
Allow the hosts for accessories to be specified by host or role, or on
all app hosts by setting `daemon: true`.

```
  # Single host
  mysql:
    host: 1.1.1.1
  # Multiple hosts
  redis:
    hosts:
      - 1.1.1.1
      - 1.1.1.2
  # By role
  monitoring:
    roles:
      - web
      - jobs
```
2023-03-28 13:26:27 +01:00
David Heinemeier Hansson
fd5313ec3e Merge pull request #163 from milk1000cc/rolify-app-logs
Rolify app logs cli/command
2023-03-28 14:13:02 +02:00
David Heinemeier Hansson
4184d3204e Merge pull request #161 from tbuehlmann/push-latest-image
Push <image>:latest in addition to <image>:<git-ref>
2023-03-28 14:09:32 +02:00
milk1000cc
15a41d3fd8 Follow web role logs when no roles are specified 2023-03-28 09:02:42 +09:00
milk1000cc
03614bfb79 Rolify app logs cli/command 2023-03-27 23:08:46 +09:00
Tobias Bühlmann
078d68b170 Push <image>:latest in addition to <image>:<git-ref> 2023-03-27 12:52:11 +02:00
David Heinemeier Hansson
cec82ac641 Merge pull request #158 from basecamp/zero-downtime-redeploys 2023-03-24 18:27:29 +01:00
Donal McBreen
05488e4c1e Zero downtime redeploys
When deploying check if there is already a container with the existing
name. If there is rename it to "<version>_<random_hex_string>" to remove
the name clash with the new container we want to boot.

We can then do the normal zero downtime run/wait/stop.

While implementing this I discovered the --filter name=foo does a
substring match for foo, so I've updated those filters to do an exact
match instead.
2023-03-24 17:09:20 +00:00
David Heinemeier Hansson
01a2b678d7 Merge pull request #154 from basecamp/lock-deploys
Deploy locks
2023-03-24 15:50:33 +01:00
David Heinemeier Hansson
84540cee7b Merge branch 'main' into pr/154
* main: (32 commits)
  Inline default as with other options
  Symbols!
  Fix tests
  test stop with custom stop wait time
  No need to replicate Docker default
  Describe purpose rather than elements
  Style and ordering
  Customizable stop wait time
  Fix tests
  Ensure it also works when configuring just log options without setting a driver
  Add accessory test
  Undo change
  Improve test
  Update README
  Ensure default log option `max-size=10m`
  #142 Allow to customize container options in accessories
  Fix flaky test
  Fix tests
  More resilient tests
  Fix other tests
  ...
2023-03-24 15:43:17 +01:00
David Heinemeier Hansson
5bbb4aeb58 Merge pull request #131 from calmyournerves/global-logging-config
Global logging configuration
2023-03-24 15:36:11 +01:00
David Heinemeier Hansson
6a27a46e5f Inline default as with other options 2023-03-24 15:34:34 +01:00
David Heinemeier Hansson
b5ccc1fa5d Merge branch 'main' into global-logging-config 2023-03-24 15:32:41 +01:00
David Heinemeier Hansson
e2e5e18af9 Merge pull request #155 from basecamp/gracefully-shut-down-containers
Customizable stop wait time
2023-03-24 15:31:14 +01:00
David Heinemeier Hansson
4fa71834ad Symbols! 2023-03-24 15:27:11 +01:00
David Heinemeier Hansson
65663ae2ea Merge branch 'main' into pr/155
* main:
  Describe purpose rather than elements
  Style and ordering
  #142 Allow to customize container options in accessories
2023-03-24 15:25:45 +01:00
Samuel Sieg
4044abdde1 Fix tests 2023-03-24 15:25:29 +01:00
Samuel Sieg
bc64a07a95 Merge branch 'main' into global-logging-config 2023-03-24 15:24:06 +01:00
David Heinemeier Hansson
fdb2502216 test stop with custom stop wait time 2023-03-24 15:22:34 +01:00
David Heinemeier Hansson
a9bb8d7376 No need to replicate Docker default 2023-03-24 15:18:18 +01:00
David Heinemeier Hansson
53095a053e Describe purpose rather than elements 2023-03-24 15:16:38 +01:00
David Heinemeier Hansson
4ab5199853 Style and ordering 2023-03-24 15:16:15 +01:00
David Heinemeier Hansson
348f5844d5 Merge pull request #153 from javierav/feature/accessory-options
#142 Allow to customize container options in accessories
2023-03-24 15:09:12 +01:00
Jacopo
9b43a6b23b Customizable stop wait time
Configurable via a global `stop_wait_time` option.
The default is `10` which matches Docker defaults.
2023-03-24 15:04:45 +01:00
David Heinemeier Hansson
1f196045a9 Merge pull request #99 from tbuehlmann/role-awareness
Role aware container names
2023-03-24 15:01:34 +01:00
Samuel Sieg
86e99fb079 Merge branch 'main' into global-logging-config 2023-03-24 14:40:27 +01:00
David Heinemeier Hansson
494e29d672 Fix tests 2023-03-24 14:35:17 +01:00
David Heinemeier Hansson
93423f2f20 Merge branch 'main' into pr/99
* main:
  Wording
  Remove accessory images using tags rather than labels
  Update readme to point to ghcr.io/mrsked/mrsk
  Validate that all roles have hosts
  Commander needn't accumulate configuration
  Pull latest image tag, so we can identity it
  Default to deploying the config version
  Remove unneeded Dockerfile.dind, update Readme
  add D-in-D dockerfile, update Readme
2023-03-24 14:26:31 +01:00
Donal McBreen
8d8f9f6ada Deploy locks
Add a deploy lock for commands that are unsafe to run concurrently.

The lock is taken by creating a `mrsk_lock` directory on the primary
host. Details of who took the lock are added to a details file in that
directory.

Additional CLI commands have been added to manual release and acquire
the lock and to check its status.

```
Commands:
  mrsk lock acquire -m, --message=MESSAGE  # Acquire the deploy lock
  mrsk lock help [COMMAND]                 # Describe subcommands or one specific subcommand
  mrsk lock release                        # Release the deploy lock
  mrsk lock status                         # Report lock status

Options:
  -v, [--verbose], [--no-verbose]                # Detailed logging
  -q, [--quiet], [--no-quiet]                    # Minimal logging
      [--version=VERSION]                        # Run commands against a specific app version
  -p, [--primary], [--no-primary]                # Run commands only on primary host instead of all
  -h, [--hosts=HOSTS]                            # Run commands on these hosts instead of all (separate by comma)
  -r, [--roles=ROLES]                            # Run commands on these roles instead of all (separate by comma)
  -c, [--config-file=CONFIG_FILE]                # Path to config file
                                                 # Default: config/deploy.yml
  -d, [--destination=DESTINATION]                # Specify destination to be used for config file (staging -> deploy.staging.yml)
  -B, [--skip-broadcast], [--no-skip-broadcast]  # Skip audit broadcasts
```

If we add support for running multiple deployments on a single server
we'll need to extend the locking to lock per deployment.
2023-03-24 12:28:08 +00:00
David Heinemeier Hansson
17e74910e4 Merge pull request #150 from basecamp/remove-accessory-image
Remove accessory images using tags rather than labels
2023-03-24 13:21:15 +01:00
David Heinemeier Hansson
8ebcafd3d8 Wording 2023-03-24 13:20:52 +01:00
David Heinemeier Hansson
89b4b909db Merge pull request #118 from kumulustech/kumulus/docker-in-docker
Add docker in docker to Dockerfile for container dev
2023-03-24 13:19:33 +01:00
David Heinemeier Hansson
c89b77127b Merge pull request #143 from djmb/default-to-deploying-config-version
Default to deploying the config version
2023-03-24 12:36:20 +01:00
Samuel Sieg
9c27ead21f Ensure it also works when configuring just log options without setting a driver 2023-03-24 09:38:02 +01:00
Samuel Sieg
c3de89bb59 Add accessory test 2023-03-24 09:19:13 +01:00
Samuel Sieg
20a6bc31cd Undo change 2023-03-24 09:15:37 +01:00
Samuel Sieg
ba5bdf95ec Improve test 2023-03-24 09:15:30 +01:00
Samuel Sieg
3392fc6c1b Update README 2023-03-24 09:15:03 +01:00
Samuel Sieg
7369be48ff Ensure default log option max-size=10m 2023-03-24 09:10:36 +01:00
Samuel Sieg
4670db7f6d Merge branch 'main' into global-logging-config 2023-03-24 08:35:43 +01:00
Jeremy Daer
e859a581ab Remove accessory images using tags rather than labels 2023-03-23 15:59:28 -07:00
Javier Aranda
5d5d58a4ec #142 Allow to customize container options in accessories 2023-03-23 23:56:59 +01:00
Robert Starmer
cf38feb1d6 Update readme to point to ghcr.io/mrsked/mrsk 2023-03-23 12:35:15 -07:00
David Heinemeier Hansson
e2d10ec5a9 Merge pull request #145 from basecamp/config-version
Commander needn't accumulate configuration
2023-03-23 17:51:30 +01:00
Jeremy Daer
035e4afff7 Validate that all roles have hosts 2023-03-23 08:57:34 -07:00
Jeremy Daer
1887a6518e Commander needn't accumulate configuration
Commander had version/destination solely to incrementally accumulate CLI
options. Simpler to configure in one shot.

Clarifies responsibility and lets us introduce things like
`abbreviated_version` in one spot - Configuration.
2023-03-23 08:57:32 -07:00
Donal McBreen
1ed4a37da2 Pull latest image tag, so we can identity it
`docker image ls` doesn't tell us what the latest deployed image is (e.g
if we've rolled back). Pull the latest image tag through to the server
so we can use it instead.
2023-03-23 14:39:32 +00:00
David Heinemeier Hansson
7e1596e722 Fix flaky test 2023-03-23 15:36:02 +01:00
David Heinemeier Hansson
e7e3cd98eb Fix tests 2023-03-23 15:16:10 +01:00
David Heinemeier Hansson
a1fc00347b Merge branch 'main' into pr/99
* main:
  Ask for access token
  Style
  Style
  config.traefik is already nil safe
  Update README.md
  Bump dev deps and consolidate platform matches
  Deploys mention the released service@version
  Accessories aren't required to publish a port
  Accessories may be pulled from authenticated registries
  Polish destination config loading
  Allow arbitrary docker options for traefik
  Fixed typos
  Fixed readme
  Rebased on main
  Added volume configuration in response to issue coments
  Modified in response to PR comments
  Added the additional_ports configuration
2023-03-23 14:48:13 +01:00
David Heinemeier Hansson
f73c526890 Ask for access token 2023-03-23 14:46:41 +01:00
David Heinemeier Hansson
65b90dd5c8 Merge branch 'main' into default-to-deploying-config-version 2023-03-23 14:42:31 +01:00
David Heinemeier Hansson
9648721ce7 Merge pull request #146 from basecamp/tell-me-more
Deploys mention the service and version
2023-03-23 14:38:31 +01:00
David Heinemeier Hansson
e409281bb2 Merge pull request #147 from basecamp/destination-config-polish
Polish destination config loading
2023-03-23 14:35:29 +01:00
David Heinemeier Hansson
bab8e42965 Merge pull request #151 from basecamp/portless-accessories
Accessories aren't required to publish a port
2023-03-23 14:32:58 +01:00
David Heinemeier Hansson
110df5244b Merge pull request #152 from basecamp/deps
Bump dev deps and consolidate platform matches
2023-03-23 14:31:22 +01:00
David Heinemeier Hansson
01d684746e Merge pull request #100 from stepbeekio/feature/multiple-traefik-entrypoints
Added the docker options override configuration for traefik
2023-03-23 14:28:40 +01:00
David Heinemeier Hansson
951a71f38e Style 2023-03-23 14:26:12 +01:00
David Heinemeier Hansson
8b755c6973 Style 2023-03-23 14:24:34 +01:00
David Heinemeier Hansson
9a909ba7eb config.traefik is already nil safe 2023-03-23 14:06:15 +01:00
David Heinemeier Hansson
14512fe409 Update README.md 2023-03-23 12:10:56 +01:00
David Heinemeier Hansson
e97216b0ea Merge pull request #149 from basecamp/private-accessories
Private accessory images
2023-03-23 09:57:39 +01:00
Jeremy Daer
f3d93d3899 Bump dev deps and consolidate platform matches 2023-03-23 01:40:05 -07:00
Jeremy Daer
53d7f9d528 Deploys mention the released service@version
Less work for broadcast commands to take on.

Also fixes a bug where rollback on hosts without a running container
would stop the container they had just started.
2023-03-23 01:09:25 -07:00
Jeremy Daer
c870e560c1 Accessories aren't required to publish a port
Allows for background accessories like schedulers that don't act
as typical network service dependencies and have no port to expose.
2023-03-23 00:10:30 -07:00
Jeremy Daer
04b1d5e49e Accessories may be pulled from authenticated registries 2023-03-22 23:48:22 -07:00
Robert Starmer
714960f184 Merge branch 'main' into kumulus/docker-in-docker 2023-03-22 11:27:28 -07:00
Jeremy Daer
c0d5b48f22 Polish destination config loading
* `Pathname#sub_ext` to munge .yml ext to .destination.yml
* Extract multi-file config merge
2023-03-22 10:38:37 -07:00
Donal McBreen
fb3353084f Default to deploying the config version
If we don't supply a version when deploying we'll use the result of
docker image ls to decide which image to boot. But that doesn't
necessarily correspond to the one we have just built.

E.g. if you do something like:

```
mrsk deploy        # deploys git sha AAAAAAAAAAAAAA
git commit --amend # update the commit message
mrsk deploy        # deploys git sha BBBBBBBBBBBBBB
```

In this case running `docker image ls` will give you the same image
twice (because the contents are identical) with tags for both SHAs but
the image we have just built will not be returned first. Maybe the order
is random, but it always seems to come second as far as I have seen.

i.e you'll get something like:

```
REPOSITORY    TAG              IMAGE ID       CREATED          SIZE
foo/bar       AAAAAAAAAAAAAA   6272349a9619   31 minutes ago   791MB
foo/bar       BBBBBBBBBBBBBB   6272349a9619   31 minutes ago   791MB
```

Since we already know what version we want to deploy from the config,
let's just pass that through.
2023-03-22 16:14:50 +00:00
David Heinemeier Hansson
19104cafb4 Merge branch 'main' into role-awareness 2023-03-21 08:20:26 -04:00
Samuel Sieg
1bdfc217c4 Merge branch 'main' into global-logging-config 2023-03-21 13:20:12 +01:00
David Heinemeier Hansson
83dc82661b Merge pull request #125 from calmyournerves/fix-destination-filter
Fix label filters when destination is passed
2023-03-21 07:44:59 -04:00
David Heinemeier Hansson
790be0f5f3 Style 2023-03-21 12:42:04 +01:00
David Heinemeier Hansson
49d60a045a Style 2023-03-21 12:41:28 +01:00
David Heinemeier Hansson
60faf27a05 More resilient tests 2023-03-20 17:40:36 +01:00
David Heinemeier Hansson
43d1ecc94b Fix other tests 2023-03-20 17:33:13 +01:00
David Heinemeier Hansson
00b970323b Merge branch 'main' into pr/99
* main:
  Add another assertion for `escape_shell_value`
  Add tests for `Mrsk::Utils`
  Fix indentation
  Don't report exception here too
  Don't report exception
  Add CLI tests for remaining commands that are not tested yet
  Minor: Properly require active_support
2023-03-20 17:31:50 +01:00
David Heinemeier Hansson
d0c4030257 Merge pull request #128 from calmyournerves/utils-tests
Tests for `Mrsk::Utils`
2023-03-20 02:28:42 -04:00
Robert Starmer
9591096131 Merge branch 'main' into kumulus/docker-in-docker 2023-03-19 12:34:32 -07:00
Samuel Sieg
b635b3198f Fix 2023-03-19 09:49:23 +01:00
Samuel Sieg
662873de49 Add logging to README 2023-03-19 09:48:54 +01:00
Samuel Sieg
b5372988f7 Add global logging configuration 2023-03-19 09:21:08 +01:00
Samuel Sieg
c3d0382935 Add another assertion for escape_shell_value 2023-03-17 16:31:10 +01:00
Samuel Sieg
2de5250486 Add tests for Mrsk::Utils 2023-03-17 16:29:25 +01:00
Samuel Sieg
491777221f Fix destination label filter 2023-03-16 16:15:31 +01:00
David Heinemeier Hansson
d167e48584 Merge pull request #122 from calmyournerves/add-cli-tests
Add CLI tests for remaining commands that are not tested yet
2023-03-16 09:31:28 -04:00
David Heinemeier Hansson
d071246865 Merge pull request #119 from ylecuyer/active_support-yle
Minor: Properly require active_support
2023-03-16 09:29:34 -04:00
Samuel Sieg
dae8b14469 Fix indentation 2023-03-16 08:35:12 +01:00
Samuel Sieg
b166f3fbf4 Don't report exception here too 2023-03-16 08:29:10 +01:00
Samuel Sieg
d33b723afb Don't report exception 2023-03-16 08:24:54 +01:00
Samuel Sieg
aae290cefc Add CLI tests for remaining commands that are not tested yet 2023-03-15 16:48:12 +01:00
Stephen van Beek
4c542930c5 Allow arbitrary docker options for traefik 2023-03-15 15:37:10 +00:00
Tobias Bühlmann
a15603655c Adapt test for single host 2023-03-15 09:28:10 +01:00
Robert Starmer
11af999800 Remove unneeded Dockerfile.dind, update Readme 2023-03-14 16:27:19 -07:00
David Heinemeier Hansson
cb824bdc42 Merge branch 'main' into role-awareness 2023-03-14 19:11:10 -04:00
Yoann Lecuyer
85a0267447 Minor: Properly require active_support 2023-03-14 23:29:00 +01:00
Robert Starmer
886914c82e Merge branch 'main' into kumulus/docker-in-docker 2023-03-14 14:14:07 -07:00
Robert Starmer
5b506a2daa add D-in-D dockerfile, update Readme 2023-03-14 14:14:02 -07:00
Stephen van Beek
9843c5e1ce Fixed typos 2023-03-14 20:13:13 +00:00
Stephen van Beek
c2ca269eb6 Fixed readme 2023-03-14 20:12:11 +00:00
Stephen van Beek
53046efad4 Rebased on main 2023-03-14 20:11:09 +00:00
Stephen van Beek
2db1bfde00 Added volume configuration in response to issue coments 2023-03-14 19:59:19 +00:00
Stephen van Beek
2cea12c56b Modified in response to PR comments 2023-03-14 19:59:19 +00:00
Stephen van Beek
43a1b42f8c Added the additional_ports configuration
ISSUE: https://github.com/mrsked/mrsk/issues/98
2023-03-14 19:59:19 +00:00
David Heinemeier Hansson
c282461265 Merge pull request #116 from tbuehlmann/traefik-command-options
Properly pass traefik command options
2023-03-14 15:08:27 -04:00
David Heinemeier Hansson
dcbe038555 Merge pull request #117 from calmyournerves/cli-main-tests
Add tests for main CLI commands
2023-03-14 15:07:07 -04:00
Samuel Sieg
3fd2f3f2c5 Improve comments 2023-03-14 16:05:57 +01:00
Samuel Sieg
46dad1ee6c Add tests for main CLI commands 2023-03-14 15:58:12 +01:00
Tobias Bühlmann
3ca5bc50b6 Properly pass traefik command options
Traefik command options need to be passed as `--key=value`, not `--key value`.
2023-03-14 15:04:33 +01:00
David Heinemeier Hansson
b668ce3f25 Merge pull request #111 from calmyournerves/deploy-without-build-push 2023-03-14 07:32:27 -04:00
David Heinemeier Hansson
253d4ac37b Merge pull request #115 from intrip/fix-traefik-default-middleware 2023-03-14 07:31:20 -04:00
Jacopo
50ee954ca9 Fix Traefik retry middleware
As per [Traefik docs](https://doc.traefik.io/traefik/middlewares/overview/#configuration-example)
a middleware to be activated needs to be applied to a route. Change the default settings
to apply the `retry` middleware on every role with Traefik enabled.
2023-03-14 12:15:00 +01:00
Samuel Sieg
0ac2cd2a4b Add tests for deploy/redeploy commands 2023-03-14 11:49:31 +01:00
Tobias Bühlmann
72e0184e9f Fix failing tests 2023-03-13 17:36:02 +01:00
Samuel Sieg
577cf2cec9 Merge branch 'main' into deploy-without-build-push 2023-03-13 16:11:38 +01:00
Samuel Sieg
5010850b86 Merge branch 'main' into deploy-without-build-push 2023-03-13 16:10:31 +01:00
David Heinemeier Hansson
fa07c2403c Merge pull request #113 from moomerman/fix-healthcheck-test
Fix healthcheck test
2023-03-13 16:10:17 +01:00
Samuel Sieg
c29d1ddeba Fix 2023-03-13 16:05:21 +01:00
Samuel Sieg
cb15800d25 Move option to deploy/redeploy, rename to skip-push 2023-03-13 16:02:24 +01:00
Richard Taylor
3e0b71b631 Fix healthcheck test
Looks like the tests started failing on the options healthcheck PR
after merging the container name env var PR.
2023-03-13 14:51:54 +00:00
David Heinemeier Hansson
9b666e54f3 Update README.md 2023-03-13 10:43:44 -04:00
David Heinemeier Hansson
d2f76dac6b Merge branch 'main' into role-awareness 2023-03-13 15:16:44 +01:00
David Heinemeier Hansson
bf3d3f3ba7 Merge pull request #101 from davegudge/fix-docker-publish
fix: GitHub Workflow: Docker Publish
2023-03-13 15:14:06 +01:00
David Heinemeier Hansson
20733a4493 Merge pull request #102 from moomerman/cmd-options-for-healthcheck
Use custom web options for healthcheck
2023-03-13 15:12:25 +01:00
David Heinemeier Hansson
a267c1e835 Merge pull request #103 from 99linesofcode/fix-dockerfile-buildx
Install buildx inside container
2023-03-13 15:11:37 +01:00
David Heinemeier Hansson
c1c26a154d Merge pull request #104 from moomerman/add-container-name-env-var
Add container name env var for containers
2023-03-13 15:10:02 +01:00
David Heinemeier Hansson
5969ff66d5 Merge pull request #107 from clowder/order-options-dig
Avoid `[ActiveSupport::OrderedOptions#dig]`
2023-03-13 15:08:31 +01:00
David Heinemeier Hansson
b1f5165dc0 Merge pull request #108 from clowder/patch-1
Update `accessory remove` description and warning
2023-03-13 15:07:47 +01:00
David Heinemeier Hansson
cce0fafdc4 Merge pull request #110 from kjellberg/patch-1
Update README.md to reflect backtick escaping in Utils.optionize
2023-03-13 15:07:09 +01:00
Samuel Sieg
6232175ef8 Undo changes from experimenting 2023-03-12 10:56:12 +01:00
Samuel Sieg
47af6d9483 Is a global option better? 2023-03-12 10:53:29 +01:00
Samuel Sieg
ff0170076e Simplify 2023-03-12 10:44:33 +01:00
Samuel Sieg
9b39f2f3ab Keep it simple for the proposal 2023-03-12 10:41:04 +01:00
Rasmus Kjellberg
600902ef5e Update README.md
Backticks are handled by `Utils.optionize`
2023-03-12 07:39:07 +01:00
Richard Taylor
bb241dea43 Add container name env var for containers
Because the container name is generated it isn't possible to
determine this inside the container.

This adds the MRSK_CONTAINER_NAME env var when running the
container so it can be read by the service running inside the
container.
2023-03-11 10:14:41 +00:00
Chris Lowder
f26beeaa9f Update accessory remove description and warning
Make it clear the accessory's data directory will also be removed.
2023-03-10 20:51:14 +00:00
Chris Lowder
41a5cb2a04 Avoid [ActiveSupport::OrderedOptions#dig]
The implementation has been updated upstream[^1] to expect symbolized
keys. MRSK relies heavily on the fact that nested keys are strings, so
we're removing existing uses of `#dig`.

[^1]: 5c15b586aa
2023-03-10 19:45:35 +00:00
Chris Lowder
643cb2c520 Include edge Rails in the build matrix
Highlighting an incompatibility with the new implementation of
`[ActiveSupport::OrderedOptions#dig]`.

[^1]: 5c15b586aa
2023-03-10 19:40:57 +00:00
Jordy Schreuders
b2c819fe32 Add README section on running MRSK from Docker 2023-03-10 19:23:14 +02:00
Jordy Schreuders
439b681308 Neglected to install buildx inside container 2023-03-10 18:13:32 +02:00
Richard Taylor
e5c5e89232 Use custom web options for healthcheck
If the web role has custom options, ensure these are used for the
healthcheck.
2023-03-10 15:55:04 +00:00
Samuel Sieg
4bf77ccd1b Allow deploy/deliver without building and pushing the image 2023-03-10 11:26:35 +01:00
Dave Gudge
57e9231c5e fix: Github Workflow: Docker Publish
The workflow was failing with:

```
The workflow is not valid. .github/workflows/docker-publish.yml (Line: 22, Col: 14): Unexpected symbol: '|'. Located at position 12 within expression: github.ref | replace('refs/tags/', '')
```

The `set-output` command is deprecated, so the issue has been fixed by utilising the `github.ref_name` context to retrieve the version tag that triggered the workflow.

> `github.ref_name`: The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`.

https://docs.github.com/en/actions/learn-github-actions/contexts
2023-03-10 10:02:33 +00:00
Tobias Bühlmann
ccf8762c98 Reuse web container per default 2023-03-10 10:50:26 +01:00
Tobias Bühlmann
418bc13ae7 Apply filters correctly 2023-03-10 10:33:55 +01:00
Tobias Bühlmann
7d4dfc4c86 Pass role names for simplicity 2023-03-10 09:18:47 +01:00
Tobias Bühlmann
fdb0c8ee91 Rolify app cli/command 2023-03-10 08:50:26 +01:00
Tobias Bühlmann
6b11303230 Prepare auditor to print a present role 2023-03-09 20:55:37 +01:00
Tobias Bühlmann
901484d75d Filter roles and hosts by their respective counterpart 2023-03-09 18:21:39 +01:00
Tobias Bühlmann
e178907a21 Don't list duplicate hosts 2023-03-09 16:46:57 +01:00
David Heinemeier Hansson
3026a92c98 Merge pull request #71 from tbuehlmann/destination-awareness
Destination aware container names
2023-03-09 13:25:49 +00:00
David Heinemeier Hansson
ab7c6c6540 Use compact.join strategy here too 2023-03-09 14:24:19 +01:00
David Heinemeier Hansson
11f4dbfc5f Bump version for 0.9.1 2023-03-09 14:11:42 +01:00
David Heinemeier Hansson
15e879e83c Merge pull request #97 from martinbjeldbak/syntax-error-docker-install
Fix syntax error in dependency install step
2023-03-09 13:11:22 +00:00
Martin Bjeldbak Madsen
96180f9bd0 Fix syntax error in docker install exec 2023-03-09 22:34:11 +11:00
David Heinemeier Hansson
2f454c39e7 Bump version for 0.9.0 2023-03-09 11:22:44 +01:00
David Heinemeier Hansson
12f5b780b8 Merge pull request #93 from calmyournerves/update-readme-dockerfile-context
Update README with `dockerfile` and `context` builder options
2023-03-09 10:21:19 +00:00
David Heinemeier Hansson
3b7836f8e3 Merge pull request #95 from mrsked/cmd-args-for-roles
Custom options per role
2023-03-09 10:20:50 +00:00
David Heinemeier Hansson
64cc081f10 Explain container options 2023-03-09 11:20:28 +01:00
David Heinemeier Hansson
1f784176b7 Allow value-less options with true 2023-03-09 11:17:28 +01:00
David Heinemeier Hansson
d3f07d6313 Allow custom options per role 2023-03-09 11:09:19 +01:00
David Heinemeier Hansson
98a14f6173 Add cmd args for roles 2023-03-09 11:01:06 +01:00
David Heinemeier Hansson
487fcd4cea Only used for traefik 2023-03-09 11:00:52 +01:00
David Heinemeier Hansson
c8badea6dd Extract argumentization for cmd and add proper escaping 2023-03-09 10:54:53 +01:00
Samuel Sieg
16896fa8ad 💅 2023-03-09 10:15:07 +01:00
Samuel Sieg
716103590d Keep it simple 2023-03-09 10:14:29 +01:00
Samuel Sieg
a9be6cc838 Add builder options for dockerfile and context to README 2023-03-09 10:12:53 +01:00
David Heinemeier Hansson
5a3ea24c6b Merge pull request #77 from calmyournerves/dockerfile-context-build-options
Allow setting the Dockerfile and the Docker build context when building
2023-03-09 08:32:57 +00:00
David Heinemeier Hansson
a06c19633c Merge pull request #92 from kjellberg/fix-traefik-host-port
fix: mrsk run command fails when traefik config is empty
2023-03-09 08:31:20 +00:00
David Heinemeier Hansson
46bec120c8 Test running without special config 2023-03-09 09:30:09 +01:00
David Heinemeier Hansson
0431bb5f97 Extract named constant and method 2023-03-09 09:29:56 +01:00
Samuel Sieg
2b95cdf8d0 Merge branch 'main' into dockerfile-context-build-options 2023-03-09 08:54:23 +01:00
Rasmus
eacdf34540 fix: mrsk deploy fails when traefik config is empty 2023-03-08 18:55:04 +01:00
David Heinemeier Hansson
7f0e6f1f13 Merge pull request #85 from clowder/traefik-host-port
Customizable Traefik host port
2023-03-08 17:06:51 +00:00
David Heinemeier Hansson
2e9d877185 Merge pull request #88 from simonrand/ensure-curl-is-available
Ensure curl is installed on hosts during bootstrapping
2023-03-08 17:05:40 +00:00
David Heinemeier Hansson
347046019f Add test 2023-03-08 18:05:06 +01:00
David Heinemeier Hansson
3457c3f606 Style 2023-03-08 18:05:00 +01:00
David Heinemeier Hansson
155384472a Allow primary host even when a specific role has been set 2023-03-08 18:00:13 +01:00
David Heinemeier Hansson
32ab79c0cc Merge pull request #91 from kjellberg/lookup_username
Allow registry username to reference a secret
2023-03-08 16:44:09 +00:00
David Heinemeier Hansson
a4d576f105 Test ENV username 2023-03-08 17:43:29 +01:00
David Heinemeier Hansson
b809a971e2 One purpose per method 2023-03-08 17:43:23 +01:00
Rasmus
f531874be4 Allow registry username to be a reference to secret 2023-03-07 10:13:49 +01:00
Tobias Bühlmann
8b913068de Add destination to healthcheck containers names 2023-03-06 16:54:13 +01:00
Simon Rand
9ae3886b2b Ensure curl is installed during bootstrapping 2023-03-05 16:51:07 +00:00
Chris Lowder
963b96ff62 Customizable Traefik host port
Allow users to free up port 80 on the host machine, without losing
Traefik's Docker routing super-powers.
2023-03-05 13:13:22 +00:00
David Heinemeier Hansson
8c69990dbb Merge pull request #82 from AxelTheGerman/ed25519
Add ed25519 and bcrypt_pbkdf to gemspec
2023-03-05 09:35:26 +01:00
Axel Gustav
3b6571ae55 Make sure ed25519 and bcrypt_pbkdf are in gemspec dependencies 2023-03-04 17:07:34 -04:00
David Heinemeier Hansson
013121c55d Merge pull request #80 from kjellberg/patch-1
Publish Docker image for each release
2023-03-04 17:27:15 +01:00
Rasmus Kjellberg
059979b889 Update docker-publish.yml 2023-03-04 17:14:02 +01:00
Rasmus Kjellberg
11267b43c2 Publish and tag docker on a new version release 2023-03-04 17:05:50 +01:00
David Heinemeier Hansson
41168e8c23 Merge pull request #79 from calmyournerves/patch-1
Small README fixes
2023-03-04 15:19:23 +01:00
Samuel Sieg
cf73ae67a5 Fix README 2023-03-04 14:07:20 +01:00
Samuel Sieg
ff88ee0b22 Allow setting the build context used for building 2023-03-04 10:59:52 +01:00
Samuel Sieg
b6934b0f41 Allow configuring the Dockerfile used for building 2023-03-04 10:59:23 +01:00
David Heinemeier Hansson
e160b29693 Merge pull request #72 from kjellberg/patch-1
Update CONTRIBUTING.md
2023-03-04 08:50:03 +01:00
David Heinemeier Hansson
8ef88859ec Build image on push 2023-03-04 08:23:24 +01:00
David Heinemeier Hansson
9c8bbb8640 Merge pull request #73 from kjellberg/dockerfile
Create Dockerfile
2023-03-04 08:18:48 +01:00
David Heinemeier Hansson
8faef72d33 Already created by WORKDIR 2023-03-04 08:15:32 +01:00
Rasmus
81cbd760d5 Group RUN commands - reduce image size 2023-03-04 07:38:29 +01:00
Rasmus
57b1a474fe Create Dockerfile 2023-03-03 23:57:07 +01:00
Rasmus
38b8fe0d55 Added CODE_OF_CONDUCT.md 2023-03-03 19:17:35 +01:00
Rasmus Kjellberg
dcc4db1137 Update CONTRIBUTING.md 2023-03-03 17:44:04 +01:00
Tobias Bühlmann
170562c7e7 Let App be aware of destination 2023-03-03 15:29:00 +01:00
David Heinemeier Hansson
78927aa7a2 Create CONTRIBUTING.md 2023-03-03 15:00:56 +01:00
David Heinemeier Hansson
cec3468f50 Merge pull request #64 from jimt/typos-1
Fix typos
2023-03-02 10:26:52 +01:00
Jim Tittsler
cef13a2fe5 Fix typos 2023-03-02 10:48:12 +09:00
David Heinemeier Hansson
f9d6ffa746 Merge pull request #59 from lvnilesh/patch-1
add bitwarden erb for mrsk envify
2023-03-01 09:09:02 +01:00
David Heinemeier Hansson
8c8deb2e13 Update README.md 2023-03-01 09:05:22 +01:00
Nilesh Londhe
fa7b560d50 Update README.md 2023-02-28 17:24:05 -08:00
Nilesh Londhe
f7b0b9ac92 add bitwarden erb for mrsk envify
add bitwarden erb for mrsk envify
2023-02-28 17:22:11 -08:00
David Heinemeier Hansson
fcf226f790 Bump version for 0.8.4 2023-02-27 12:59:58 +01:00
David Heinemeier Hansson
2004cdaa0d Fix test 2023-02-27 12:59:41 +01:00
David Heinemeier Hansson
b8413b3ab5 Recover README changes
Force push bad 😄
2023-02-26 11:34:32 +01:00
David Heinemeier Hansson
701f6ff237 Move sleep note out of host loop, so we only see it once 2023-02-26 11:27:19 +01:00
David Heinemeier Hansson
27279c6c82 Accessories can individually ask for confirmation 2023-02-23 15:41:49 +01:00
David Heinemeier Hansson
08dd468d87 Bump version for 0.8.3 2023-02-23 15:34:18 +01:00
David Heinemeier Hansson
9a4f502cc4 Pass confirmed flag to accessories 2023-02-23 15:31:56 +01:00
David Heinemeier Hansson
11e6f7914d Merge pull request #56 from mrsked/more-resilient-zero-downtime-deploy
Start before stopping and longer timeouts
2023-02-23 12:24:06 +01:00
David Heinemeier Hansson
bc6963e6bf Note that rebooting may cause air gap 2023-02-23 12:16:58 +01:00
David Heinemeier Hansson
f4f2b5cb17 Communicate the readiness delay 2023-02-23 12:04:57 +01:00
David Heinemeier Hansson
817336df49 No readiness delay in testing 2023-02-23 12:03:03 +01:00
David Heinemeier Hansson
4c399a74bb Update to match latest 2023-02-23 12:02:56 +01:00
David Heinemeier Hansson
e12436a1db Extract readiness_delay to config 2023-02-23 12:02:49 +01:00
David Heinemeier Hansson
b244e919bf Merge branch 'main' into more-resilient-zero-downtime-deploy
* main:
  Add option to skip audit broadcasts (useful when testing)
2023-02-23 11:52:45 +01:00
David Heinemeier Hansson
c1013543f9 Merge pull request #57 from intrip/document-cron
Example on how to set up Cron
2023-02-23 11:30:37 +01:00
Jacopo
eb46d0507e Example on how to set up Cron 2023-02-23 11:02:39 +01:00
David Heinemeier Hansson
7ad416f029 Add option to skip audit broadcasts (useful when testing) 2023-02-23 10:04:35 +01:00
David Heinemeier Hansson
371f98d67f Start before stopping and longer timeouts 2023-02-22 19:04:23 +01:00
David Heinemeier Hansson
b879412a6f Upgrade to beta! 2023-02-21 15:31:28 +01:00
David Heinemeier Hansson
e678775a18 Merge pull request #54 from intrip/print-logs-for-healthcheck-status-mistmatch
Print container logs when HealthCheck response_code != 200
2023-02-21 14:34:46 +01:00
Jacopo
689b81014b Print container logs when HealthCheck response_code != 200
The Healthcheck container is shut down right after performing the check, this
makes it harder to troubleshoot configuration issues in the healthcheck
endpoint, e.g DNS rebinding error. Printing the container logs helps the troubleshooting.
2023-02-21 11:48:29 +01:00
David Heinemeier Hansson
01a4eecf98 Bump version for 0.8.1 2023-02-20 18:21:05 +01:00
David Heinemeier Hansson
6f7422af44 Merge pull request #53 from pagbrl/fix-env-concatenation
fix(escape-cli-args): Always use quotes to escape CLI arguments
2023-02-20 18:20:28 +01:00
David Heinemeier Hansson
1fccaf60b2 Cleanup escaping logic 2023-02-20 18:20:08 +01:00
David Heinemeier Hansson
9b02a7668d Merge branch 'main' into pr/53
* main:
  Bump version for 0.8.0
  Remove images of the same name before pulling a new one
  Changed to a timeout
  Better language
  Switch to ruby-based retry
2023-02-20 18:14:47 +01:00
David Heinemeier Hansson
f6ea287e66 Bump version for 0.8.0 2023-02-20 18:06:56 +01:00
David Heinemeier Hansson
42b343436d Remove images of the same name before pulling a new one
Or you'll end up with untagged dupes.
2023-02-20 18:06:16 +01:00
David Heinemeier Hansson
9d6ccf9889 Changed to a timeout 2023-02-20 17:59:41 +01:00
David Heinemeier Hansson
c4cc9e690b Better language 2023-02-20 17:44:55 +01:00
David Heinemeier Hansson
1ccf679ca9 Switch to ruby-based retry
Retry connection errors with backoff
2023-02-20 17:42:55 +01:00
Paul Gabriel
f81ba12aa5 fix(escape): Escape double quotes and all other characters reliably 2023-02-20 16:49:47 +01:00
Paul Gabriel
25e8b91569 fix(escape-cli-args): Always use quotes to escape CLI arguments 2023-02-20 15:02:34 +01:00
Paul Gabriel
21c6a1f1ba chore(rebase): Rebase main 2023-02-20 10:27:51 +01:00
David Heinemeier Hansson
5898fdd8f4 Expand arguments to be more self-explanatory in logs 2023-02-19 18:11:06 +01:00
David Heinemeier Hansson
5299826146 Alphabetical order 2023-02-19 17:43:56 +01:00
David Heinemeier Hansson
28be8dc0f0 Encourage registry password from ENV 2023-02-19 17:42:30 +01:00
David Heinemeier Hansson
2ed3ccc53e More readable tests 2023-02-19 17:40:41 +01:00
David Heinemeier Hansson
11c726858d Point to where secrets are from 2023-02-19 17:34:49 +01:00
David Heinemeier Hansson
8706fae2b5 Reveal all options in default config 2023-02-19 17:34:06 +01:00
David Heinemeier Hansson
67d6c3acfe Think we can drop this
Now that we rescue at the top level
2023-02-19 17:33:54 +01:00
David Heinemeier Hansson
a5fd4c76ba No need for invocation 2023-02-19 17:22:03 +01:00
David Heinemeier Hansson
f3a5845501 Remember this 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
5356f31e2e Remove also removes accessories but requires confirmation 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
67cb89b9b9 Remove requires confirmation 2023-02-19 17:16:06 +01:00
David Heinemeier Hansson
745b09051e Test app remove 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
0fa70f4688 Stop app before removing it 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
6bc2def677 No need for invoke
No double action possible
2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
42bc691758 CLI doc updates
Match word

Language

Suggest what accessories are

There are also accessories

Default already shown

Better example

Warn about secrets being shown

Now also accessories

Wording

Clarifications

Clarify how to see options

General option for all

Options important here too

Hide subcommands

Implied

Simpler as just version

Be concise

Missing word

Wordsmith

Simpler and uniform words are better

Clarify what exactly we're manipulating

Wordsmithing

Implicit

Simpler language

Hide subcommands

Clarify its container management

Just one per server

Simpler
2023-02-19 17:15:44 +01:00
David Heinemeier Hansson
e5c4cb0344 Retry healthcheck for up to 10 seconds (in case container wasnt ready) 2023-02-19 15:34:36 +01:00
David Heinemeier Hansson
a0d71f3fe4 Protect against missing current version 2023-02-19 09:48:35 +01:00
David Heinemeier Hansson
389ce2f701 Only output if there's a failure 2023-02-19 09:36:04 +01:00
David Heinemeier Hansson
8e918b1906 Output logs when healthcheck fails 2023-02-19 09:33:49 +01:00
David Heinemeier Hansson
e37e5f7d09 Bump version for 0.7.2 2023-02-18 18:23:28 +01:00
David Heinemeier Hansson
7f1191bf59 Change broadcast cmd to just take an argument instead of STDIN
Simpler
2023-02-18 18:22:46 +01:00
David Heinemeier Hansson
0c03216fdf Bump version for 0.7.1 2023-02-18 16:33:28 +01:00
David Heinemeier Hansson
1973f55c58 Don't include recorded_at with broadcast line
Receiving end will already add that
2023-02-18 16:33:12 +01:00
David Heinemeier Hansson
0a51cd0899 Update for healthcheck config 2023-02-18 16:28:31 +01:00
David Heinemeier Hansson
4b0a8728f1 Bump version for 0.7.0 2023-02-18 16:27:08 +01:00
David Heinemeier Hansson
3075f8daf1 Include healthcheck in config 2023-02-18 16:26:23 +01:00
David Heinemeier Hansson
9985834bd6 Use number 2023-02-18 16:26:17 +01:00
David Heinemeier Hansson
94b4461c76 Merge pull request #52 from mrsked/health-check-with-deploy
Add healthcheck before deploy
2023-02-18 16:24:41 +01:00
David Heinemeier Hansson
7afa9e0815 Mention healthcheck as part of steps instead 2023-02-18 16:23:46 +01:00
David Heinemeier Hansson
933ece35ab Add healthcheck before deploy 2023-02-18 16:22:08 +01:00
David Heinemeier Hansson
2f80b300f0 Test rolling back to a good version too 2023-02-18 14:55:11 +01:00
David Heinemeier Hansson
2e06bf59a4 Protect against rolling back to a bad version 2023-02-18 14:33:47 +01:00
David Heinemeier Hansson
854795c2b6 Wording 2023-02-18 12:10:42 +01:00
David Heinemeier Hansson
4fe7fb705a Use same sentence style as broadcasts for audit log lines 2023-02-18 12:00:15 +01:00
David Heinemeier Hansson
270e0d0e2c Merge pull request #50 from pagbrl/labels-traefik-docs
docs(traefik-labels): Improve docs for traefik labels formatting
2023-02-18 11:42:43 +01:00
David Heinemeier Hansson
6ddc9cf017 Merge pull request #51 from mrsked/audit-broadcasts
Add audit broadcasts
2023-02-18 11:41:19 +01:00
David Heinemeier Hansson
2dcd76b2de Merge branch 'main' into audit-broadcasts
* main:
  Remove unnecessary audit recordings
2023-02-18 11:38:34 +01:00
David Heinemeier Hansson
a6eabd0b67 Remove unnecessary audit recordings 2023-02-18 11:36:52 +01:00
David Heinemeier Hansson
fb9357b5ba Add audit broadcasts 2023-02-18 11:36:30 +01:00
Paul Gabriel
d484cfcc31 docs(traefik-labels): Improve docs for traefik labels formatting 2023-02-18 00:25:30 +01:00
David Heinemeier Hansson
5c93642f2a Prepare for custom pruning 2023-02-15 20:34:08 +01:00
David Heinemeier Hansson
8ff206ba7e Highlight 2023-02-15 18:08:46 +01:00
David Heinemeier Hansson
e36a5e111c Make a note about the /up requirement 2023-02-15 18:08:26 +01:00
David Heinemeier Hansson
72522001e5 Merge pull request #46 from fschueller/fix-prune-desc
Adjust CLI description for prune command to mention 7 days
2023-02-15 14:09:06 +01:00
David Heinemeier Hansson
50c4bb83cb Bump version for 0.6.4 2023-02-15 13:48:10 +01:00
David Heinemeier Hansson
b2875ad056 More readable tests 2023-02-15 13:47:16 +01:00
David Heinemeier Hansson
8ec94f105c Tag images with service label so we can prune exclusively 2023-02-15 13:41:03 +01:00
David Heinemeier Hansson
90f4212a68 Stray copypasta 2023-02-15 13:39:53 +01:00
David Heinemeier Hansson
648894f9a9 No need for quoting 2023-02-15 13:32:59 +01:00
David Heinemeier Hansson
dc68639dfa Prune all unused images matching time filter 2023-02-15 13:32:50 +01:00
David Heinemeier Hansson
244cf8b3b7 Add prune command test 2023-02-15 13:30:31 +01:00
David Heinemeier Hansson
f25f506d77 Don't use abbreviations when we don't have to 2023-02-15 13:26:57 +01:00
David Heinemeier Hansson
c29a177a7a DRY the use of build options into one call 2023-02-15 13:23:14 +01:00
Farah Schüller
03328a998c Adjust CLI description for prune command to mention 7 days 2023-02-14 17:05:36 +01:00
David Heinemeier Hansson
ec5fad5bea Describe the vision 2023-02-11 14:30:23 +01:00
David Heinemeier Hansson
c671acf68f Bump version for 0.6.3 2023-02-11 13:10:47 +01:00
David Heinemeier Hansson
4f2cb5e184 Shorter 2023-02-11 13:00:22 +01:00
David Heinemeier Hansson
63a065237a Ensure .env file is only accessible to user 2023-02-11 12:56:57 +01:00
David Heinemeier Hansson
0f4e1888d9 Just delete the full cache directory, it isnt needed 2023-02-10 14:35:11 +01:00
David Heinemeier Hansson
d4d3308c34 Need to use args 2023-02-09 21:50:57 +01:00
David Heinemeier Hansson
b9c6d2966b Bump version for 0.6.2 2023-02-09 19:57:39 +01:00
David Heinemeier Hansson
f371cda8d8 Stick with json logger for filebeat compatibility but cap at 10mb 2023-02-09 19:56:17 +01:00
David Heinemeier Hansson
9eaf0f3b8f Lower default prune target for images to 7 days. Its just a local convenience cache. Dont risk filling up the disk on very active development. 2023-02-09 18:07:52 +01:00
David Heinemeier Hansson
a80289d046 Use local log driver for everything
Auto rotation, max is 100mb
2023-02-09 17:02:15 +01:00
David Heinemeier Hansson
aae45afb1b Easier to read tests 2023-02-09 17:01:35 +01:00
David Heinemeier Hansson
f4157c95c4 Easier to read tests 2023-02-09 16:55:09 +01:00
David Heinemeier Hansson
bb5176673b Deal with lazy-setting of configuration 2023-02-08 14:24:16 +01:00
David Heinemeier Hansson
e9cb5b64b3 Remove Fly as an example of k8s 2023-02-08 14:14:52 +01:00
David Heinemeier Hansson
0433619518 Tag new builds with latest 2023-02-08 14:08:36 +01:00
David Heinemeier Hansson
110bf44a3b Recommend single layer 2023-02-08 10:27:27 +01:00
David Heinemeier Hansson
fbdf39a733 Code highlighting 2023-02-08 08:37:33 +01:00
David Heinemeier Hansson
f99ff47f75 Make sure folks dont leak GITHUB_TOKENs into the image when using git dependencies 2023-02-08 08:35:30 +01:00
David Heinemeier Hansson
bb18189b01 Bump version for 0.6.1 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
18bdb33de2 Fix issue with removing containers triggering twice, then ensure app stop runs closer to app run on each host 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
1ec016ecad Add a brief note about Docker Swarm
A deeper comparison would be nice at some point.
2023-02-07 13:58:26 +01:00
David Heinemeier Hansson
bd61e04088 Merge pull request #38 from tbuehlmann/native-builder-image-tag-position
Move image tag to proper position
2023-02-06 09:22:57 +01:00
David Heinemeier Hansson
0da2a6408b Merge pull request #39 from adammiribyan/outside-git
Commit hash as version but not in git
2023-02-06 09:22:25 +01:00
David Heinemeier Hansson
9697a9a6e0 Merge pull request #40 from adammiribyan/gemspec
Match README
2023-02-06 09:21:57 +01:00
Adam Miribyan
32d52b024c Match README
Update gemspec description to match what's in README
2023-02-05 23:09:08 +01:00
Adam
2fe01f13df Commit hash version but not in git
Fixes #11
2023-02-05 20:31:14 +01:00
Tobias Bühlmann
554a3558ab Move image tag to proper position 2023-02-05 18:39:52 +01:00
76 changed files with 3696 additions and 757 deletions

View File

@@ -8,12 +8,15 @@ jobs:
- "2.7" - "2.7"
- "3.1" - "3.1"
- "3.2" - "3.2"
gemfile:
- Gemfile
- gemfiles/rails_edge.gemfile
continue-on-error: [false] continue-on-error: [false]
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: ${{ matrix.continue-on-error }} continue-on-error: ${{ matrix.continue-on-error }}
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

41
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Docker
on:
release:
types: [created]
tags:
- 'v*'
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/mrsked/mrsk:latest
ghcr.io/mrsked/mrsk:${{ github.ref_name }}

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
*.gem *.gem
coverage/* coverage/*
.DS_Store .DS_Store
gemfiles/*.lock

41
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,41 @@
# Contributor Code of Conduct
As contributors and maintainers of the MRSK project, we pledge to create a welcoming and inclusive environment for everyone. We value the participation of each member of our community and want all contributors to feel respected and valued.
We are committed to providing a harassment-free experience for everyone, regardless of gender, gender identity and expression, sexual orientation, disability, physical appearance, body size, race, age, or religion (or lack thereof). We do not tolerate harassment of participants in any form.
This code of conduct applies to all MRSK project spaces, including but not limited to project code, issue trackers, chat rooms, and mailing lists. Violations of this code of conduct may result in removal from the project community.
## Our standards
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Reporting
If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a project maintainer. All reports will be kept confidential and will be reviewed and investigated promptly.
We will investigate every complaint and take appropriate action. We reserve the right to remove any content that violates this Code of Conduct, or to temporarily or permanently ban any contributor for other behaviors that we deem inappropriate, threatening, offensive, or harmful.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html>.

49
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,49 @@
# Contributing to MRSK development
Thank you for considering contributing to MRSK! This document outlines some guidelines for contributing to this open source project.
Please make sure to review our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing to MRSK.
There are several ways you can contribute to the betterment of the project:
- **Report an issue?** - If the issue isnt reported, we cant fix it. Please report any bugs, feature, and/or improvement requests on the [MRSK GitHub Issues tracker](https://github.com/mrsked/mrsk/issues).
- **Submit patches** - Do you have a new feature or a fix you'd like to share? [Submit a pull request](https://github.com/mrsked/mrsk/pulls)!
- **Write blog articles** - Are you using MRSK? We'd love to hear how you're using it with your projects. Write a tutorial and post it on your blog!
## Issues
If you encounter any issues with the project, please check the [existing issues](https://github.com/mrsked/mrsk/issues) first to see if the issue has already been reported. If the issue hasn't been reported, please open a new issue with a clear description of the problem and steps to reproduce it.
## Pull Requests
Please keep the following guidelines in mind when opening a pull request:
- Ensure that your code passes the project's minitests by running ./bin/test.
- Provide a clear and detailed description of your changes.
- Keep your changes focused on a single concern.
- Write clean and readable code that follows the project's code style.
- Use descriptive variable and function names.
- Write clear and concise commit messages.
- Add tests for your changes, if possible.
- Ensure that your changes don't break existing functionality.
#### Commit message guidline
A good commit message should describe what changed and why.
## Development
The `main` branch is regularly built and tested, but it is not guaranteed to be completely stable. Tags are created regularly from release branches to indicate new official, stable release versions of MRSK.
MRSK is written in Ruby. You should have Ruby 3.2+ installed on your machine in order to work on MRSK. If that's already setup, run `bundle` in the root directory to install all dependencies. Then you can run `bin/test` to run all tests.
1. Fork the project repository.
2. Create a new branch for your contribution.
3. Write your code or make the desired changes.
4. **Ensure that your code passes the project's minitests by running ./bin/test.**
5. Commit your changes and push them to your forked repository.
6. [Open a pull request](https://github.com/mrsked/mrsk/pulls) to the main project repository with a detailed description of your changes.
## License
MRSK is released under the MIT License. By contributing to this project, you agree to license your contributions under the same license.

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# Use the official Ruby 3.2.0 Alpine image as the base image
FROM ruby:3.2.0-alpine
# Install docker/buildx-bin
COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx
# Set the working directory to /mrsk
WORKDIR /mrsk
# Copy the Gemfile, Gemfile.lock into the container
COPY Gemfile Gemfile.lock mrsk.gemspec ./
# Required in mrsk.gemspec
COPY lib/mrsk/version.rb /mrsk/lib/mrsk/version.rb
# Install system dependencies
RUN apk add --no-cache --update build-base git docker openrc \
&& rc-update add docker boot \
&& gem install bundler --version=2.4.3 \
&& bundle install
# Copy the rest of our application code into the container.
# We do this after bundle install, to avoid having to run bundle
# everytime we do small fixes in the source code.
COPY . .
# Install the gem locally from the project folder
RUN gem build mrsk.gemspec && \
gem install ./mrsk-*.gem --no-document
# Set the working directory to /workdir
WORKDIR /workdir
# Set the entrypoint to run the installed binary in /workdir
# Example: docker run -it -v "$PWD:/workdir" mrsk init
ENTRYPOINT ["mrsk"]

View File

@@ -2,7 +2,3 @@ source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" } git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec gemspec
gem "debug"
gem "mocha"
gem "railties"

View File

@@ -1,9 +1,12 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.6.1) mrsk (0.11.0)
activesupport (>= 7.0) activesupport (>= 7.0)
bcrypt_pbkdf (~> 1.0)
dotenv (~> 2.8) dotenv (~> 2.8)
ed25519 (~> 1.2)
net-ssh (~> 7.0)
sshkit (~> 1.21) sshkit (~> 1.21)
thor (~> 1.2) thor (~> 1.2)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
@@ -11,88 +14,86 @@ PATH
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actionpack (7.0.4) actionpack (7.0.4.3)
actionview (= 7.0.4) actionview (= 7.0.4.3)
activesupport (= 7.0.4) activesupport (= 7.0.4.3)
rack (~> 2.0, >= 2.2.0) rack (~> 2.0, >= 2.2.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (7.0.4) actionview (7.0.4.3)
activesupport (= 7.0.4) activesupport (= 7.0.4.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activesupport (7.0.4) activesupport (7.0.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
bcrypt_pbkdf (1.1.0)
builder (3.2.4) builder (3.2.4)
concurrent-ruby (1.1.10) concurrent-ruby (1.2.2)
crass (1.0.6) crass (1.0.6)
debug (1.7.1) debug (1.7.2)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
dotenv (2.8.1) dotenv (2.8.1)
ed25519 (1.3.0)
erubi (1.12.0) erubi (1.12.0)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.6.0) io-console (0.6.0)
irb (1.6.2) irb (1.6.3)
reline (>= 0.3.0) reline (>= 0.3.0)
loofah (2.19.1) loofah (2.20.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
method_source (1.0.0) method_source (1.0.0)
minitest (5.17.0) minitest (5.18.0)
mocha (2.0.2) mocha (2.0.2)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1) net-ssh (7.1.0)
nokogiri (1.14.0-arm64-darwin) nokogiri (1.14.2-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.0-x86_64-darwin) nokogiri (1.14.2-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.14.0-x86_64-linux) nokogiri (1.14.2-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
racc (1.6.2) racc (1.6.2)
rack (2.2.5) rack (2.2.6.4)
rack-test (2.0.2) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.4.4) rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1) loofah (~> 2.19, >= 2.19.1)
railties (7.0.4) railties (7.0.4.3)
actionpack (= 7.0.4) actionpack (= 7.0.4.3)
activesupport (= 7.0.4) activesupport (= 7.0.4.3)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rake (13.0.6) rake (13.0.6)
reline (0.3.2) reline (0.3.3)
io-console (~> 0.5) io-console (~> 0.5)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sshkit (1.21.3) sshkit (1.21.4)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
thor (1.2.1) thor (1.2.1)
tzinfo (2.0.5) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
zeitwerk (2.6.6) zeitwerk (2.6.7)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin
arm64-darwin-21 x86_64-darwin
arm64-darwin-22
x86_64-darwin-20
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES

456
README.md
View File

@@ -1,10 +1,28 @@
# MRSK # MRSK
MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. MRSK deploys web apps anywhere from bare metal to cloud VMs using Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be containerized with Docker.
Watch the screencast: https://www.youtube.com/watch?v=LL1cV2FXZ5I
Join us on Discord: https://discord.gg/YgHVT7GCXS
Ask questions: https://github.com/mrsked/mrsk/discussions
## Installation ## Installation
Install MRSK globally with `gem install mrsk`. Then, inside your app directory, run `mrsk init` (or `mrsk init --bundle` within Rails apps where you want a bin/mrsk binstub). Now edit the new file `config/deploy.yml`. It could look as simple as this: If you have a Ruby environment available, you can install MRSK globally with:
```sh
gem install mrsk
```
...otherwise, you can run a dockerized version via an alias (add this to your .bashrc or similar to simplify re-use):
```sh
alias mrsk='docker run --rm -it -v $HOME/.ssh:/root/.ssh -v /var/run/docker.sock:/var/run/docker.sock -v ${PWD}/:/workdir ghcr.io/mrsked/mrsk'
```
Then, inside your app directory, run `mrsk init` (or `mrsk init --bundle` within Rails 7+ apps where you want a bin/mrsk binstub). Now edit the new file `config/deploy.yml`. It could look as simple as this:
```yaml ```yaml
service: hey service: hey
@@ -14,7 +32,8 @@ servers:
- 192.168.0.2 - 192.168.0.2
registry: registry:
username: registry-user-name username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %> password:
- MRSK_REGISTRY_PASSWORD
env: env:
secret: secret:
- RAILS_MASTER_KEY - RAILS_MASTER_KEY
@@ -30,24 +49,49 @@ mrsk deploy
This will: This will:
1. Connect to the servers over SSH (using root by default, authenticated by your loaded ssh key) 1. Connect to the servers over SSH (using root by default, authenticated by your ssh key)
2. Install Docker on any server that might be missing it (using apt-get) 2. Install Docker on any server that might be missing it (using apt-get): root access is needed via ssh for this.
3. Log into the registry both locally and remotely 3. Log into the registry both locally and remotely
4. Build the image using the standard Dockerfile in the root of the application. 4. Build the image using the standard Dockerfile in the root of the application.
5. Push the image to the registry. 5. Push the image to the registry.
6. Pull the image from the registry on the servers. 6. Pull the image from the registry onto the servers.
7. Ensure Traefik is running and accepting traffic on port 80. 7. Ensure Traefik is running and accepting traffic on port 80.
8. Stop any containers running a previous versions of the app. 8. Ensure your app responds with `200 OK` to `GET /up`.
9. Start a new container with the version of the app that matches the current git version hash. 9. Start a new container with the version of the app that matches the current git version hash.
10. Prune unused images and stopped containers to ensure servers don't fill up. 10. Stop the old container running the previous version of the app.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Why not just run Capistrano or Kubernetes? ## Vision
MRSK basically is Capistrano for Containers, which allow us to use vanilla servers as the hosts. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the deploy servers of MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also allows for quicker deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection. In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on your own hardware, or even just have a clear migration path to do so in the future, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
Kubernetes is a beast. Running it yourself on your own hardware is not for the faint of heart. It's a fine option if you want to run on someone else's platform, like Render or Fly, but if you'd like the freedom to move between cloud and your own hardware, or even mix the two, MRSK is much simpler. You can see everything that's going on, it's just basic Docker commands being called. MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated bare metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This approach gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there are a lot of compelling options available.
Ultimately, MRSK is meant to compress the complexity of going to production using open source tooling that isn't tied to any commercial offering. Not to zero, mind you. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but as soon as those concepts are familiar, you'll be ready to go with MRSK.
## Why not just run Capistrano, Kubernetes or Docker Swarm?
MRSK basically is Capistrano for Containers, without the need to carefully prepare servers in advance. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the list of servers in MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also speeds up deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection.
Kubernetes is a beast. Running it yourself on your own hardware is not for the faint of heart. It's a fine option if you want to run on someone else's platform, either transparently [like Render](https://thenewstack.io/render-cloud-deployment-with-less-engineering/) or explicitly on AWS/GCP, but if you'd like the freedom to move between cloud and your own hardware, or even mix the two, MRSK is much simpler. You can see everything that's going on, it's just basic Docker commands being called.
Docker Swarm is much simpler than Kubernetes, but it's still built on the same declarative model that uses state reconciliation. MRSK is intentionally designed around imperative commands, like Capistrano.
Ultimately, there are a myriad of ways to deploy web apps, but this is the toolkit we're using at [37signals](https://37signals.com) to bring [HEY](https://www.hey.com) [home from the cloud](https://world.hey.com/dhh/why-we-re-leaving-the-cloud-654b47e0) without losing the advantages of modern containerization tooling.
## Running MRSK from Docker
MRSK is packaged up in a Docker container similarly to [rails/docked](https://github.com/rails/docked). This will allow you to run MRSK (from your application directory) without having to install any dependencies other than Docker. Add the following alias to your profile configuration to make working with the container more convenient:
```bash
alias mrsk="docker run -it --rm -v '${PWD}:/workdir' -v '${SSH_AUTH_SOCK}:/ssh-agent' -v /var/run/docker.sock:/var/run/docker.sock -e 'SSH_AUTH_SOCK=/ssh-agent' ghcr.io/mrsked/mrsk:latest"
```
Since MRSK uses SSH to establish a remote connection, it will need access to your SSH agent. The above command uses a volume mount to make it available inside the container and configures the SSH agent inside the container to make use of it.
## Configuration ## Configuration
@@ -60,6 +104,71 @@ MRSK_REGISTRY_PASSWORD=pw
DB_PASSWORD=secret123 DB_PASSWORD=secret123
``` ```
### Using a generated .env file
#### 1Password as a secret store
If you're using a centralized secret store, like 1Password, you can create `.env.erb` as a template which looks up the secrets. Example of a .env.erb file:
```erb
<% if (session_token = `op signin --account my-one-password-account --raw`.strip) != "" %># Generated by mrsk envify
GITHUB_TOKEN=<%= `gh config get -h github.com oauth_token`.strip %>
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Vault/Docker Hub/password" -n --session #{session_token}` %>
RAILS_MASTER_KEY=<%= `op read "op://Vault/My App/RAILS_MASTER_SECRET" -n --session #{session_token}` %>
MYSQL_ROOT_PASSWORD=<%= `op read "op://Vault/My App/MYSQL_ROOT_PASSWORD" -n --session #{session_token}` %>
<% else raise ArgumentError, "Session token missing" end %>
```
This template can safely be checked into git. Then everyone deploying the app can run `mrsk envify` when they setup the app for the first time or passwords change to get the correct `.env` file.
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`.
#### Bitwarden as a secret store
If you are using open source secret store like bitwarden, you can create `.env.erb` as a template which looks up the secrets.
You can store `SOME_SECRET` in a secure note in bitwarden vault.
```
$ bw list items --search SOME_SECRET | jq
? Master password: [hidden]
[
{
"object": "item",
"id": "123123123-1232-4224-222f-234234234234",
"organizationId": null,
"folderId": null,
"type": 2,
"reprompt": 0,
"name": "SOME_SECRET",
"notes": "yyy",
"favorite": false,
"secureNote": {
"type": 0
},
"collectionIds": [],
"revisionDate": "2023-02-28T23:54:47.868Z",
"creationDate": "2022-11-07T03:16:05.828Z",
"deletedDate": null
}
]
```
and extract the `id` of `SOME_SECRET` from the `json` above and use in the `erb` below.
Example `.env.erb` file:
```erb
<% if (session_token=`bw unlock --raw`.strip) != "" %># Generated by mrsk envify
SOME_SECRET=<%= `bw get notes 123123123-1232-4224-222f-234234234234 --session #{session_token}` %>
<% else raise ArgumentError, "session_token token missing" end %>
```
Then everyone deploying the app can run `mrsk envify` and mrsk will generate `.env`
### Using another registry than Docker Hub ### Using another registry than Docker Hub
The default registry is Docker Hub, but you can change it using `registry/server`: The default registry is Docker Hub, but you can change it using `registry/server`:
@@ -67,10 +176,14 @@ The default registry is Docker Hub, but you can change it using `registry/server
```yaml ```yaml
registry: registry:
server: registry.digitalocean.com server: registry.digitalocean.com
username: registry-user-name username:
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %> - DOCKER_REGISTRY_TOKEN
password:
- DOCKER_REGISTRY_TOKEN
``` ```
A reference to secret `DOCKER_REGISTRY_TOKEN` will look for `ENV["DOCKER_REGISTRY_TOKEN"]` on the machine running MRSK.
### Using a different SSH user than root ### Using a different SSH user than root
The default SSH user is root, but you can change it using `ssh/user`: The default SSH user is root, but you can change it using `ssh/user`:
@@ -80,6 +193,15 @@ ssh:
user: app user: app
``` ```
If you are using non-root user, you need to bootstrap your servers manually, before using them with MRSK. On Ubuntu, you'd do:
```bash
sudo apt update
sudo apt upgrade -y
sudo apt install -y docker.io curl git
sudo usermod -a -G docker ubuntu
```
### Using a proxy SSH host ### Using a proxy SSH host
If you need to connect to server through a proxy host, you can use `ssh/proxy`: If you need to connect to server through a proxy host, you can use `ssh/proxy`:
@@ -96,6 +218,13 @@ ssh:
proxy: "app@192.168.0.1" proxy: "app@192.168.0.1"
``` ```
Also if you need specific proxy command to connect to the server:
```yaml
ssh:
proxy_command: aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region=us-east-1 ## ssh via aws ssm
```
### Using env variables ### Using env variables
You can inject env variables into the app containers using `env`: You can inject env variables into the app containers using `env`:
@@ -135,6 +264,12 @@ volumes:
- "/local/path:/container/path" - "/local/path:/container/path"
``` ```
### MRSK env variables
The following env variables are set when your container runs:
`MRSK_CONTAINER_NAME` : this contains the current container name and version
### Using different roles for servers ### Using different roles for servers
If your application uses separate hosts for running jobs or other roles beyond the default web running, you can specify these hosts in a dedicated role with a new entrypoint command like so: If your application uses separate hosts for running jobs or other roles beyond the default web running, you can specify these hosts in a dedicated role with a new entrypoint command like so:
@@ -169,12 +304,13 @@ servers:
You can specialize the default Traefik rules by setting labels on the containers that are being started: You can specialize the default Traefik rules by setting labels on the containers that are being started:
``` ```yaml
labels: labels:
traefik.http.routers.hey.rule: '''Host(`app.hey.com`)''' traefik.http.routers.hey-web.rule: Host(`app.hey.com`)
``` ```
Traefik rules are in the "service-role-destination" format. The default role will be `web` if no rule is specified. If the destination is not specified, it is not included. To give an example, the above rule would become "traefik.http.routers.hey-web.rule" if it was for the "staging" destination.
Note: The extra quotes are needed to ensure the rule is passed in correctly! Note: The backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash!
This allows you to run multiple applications on the same server sharing the same Traefik instance and port. This allows you to run multiple applications on the same server sharing the same Traefik instance and port.
See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules. See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules.
@@ -195,9 +331,53 @@ servers:
my-label: "50" my-label: "50"
``` ```
### Using container options
You can specialize the options used to start containers using the `options` definitions:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
options:
cap-add: true
cpu-count: 4
```
That'll start the job containers with `docker run ... --cap-add --cpu-count 4 ...`.
### Configuring logging
You can configure the logging driver and options passed to Docker using `logging`:
```yaml
logging:
driver: awslogs
options:
awslogs-region: "eu-central-2"
awslogs-group: "my-app"
```
If nothing is configured, the default option `max-size=10m` is used for all containers. The default logging driver of Docker is `json-file`.
### Using a different stop wait time
On a new deploy, each old running container is gracefully shut down with a `SIGTERM`, and after a grace period of `10` seconds a `SIGKILL` is sent.
You can configure this value via the `stop_wait_time` option:
```yaml
stop_wait_time: 30
```
### Using remote builder for native multi-arch ### Using remote builder for native multi-arch
If you're developing on ARM64 (like Apple Silicon), but you want to deploy on AMD64 (x86 64-bit), you can use multi-archecture images. By default, MRSK will setup a local buildx configuration that does this through QEMU emulation. But this can be quite slow, especially on the first build. If you're developing on ARM64 (like Apple Silicon), but you want to deploy on AMD64 (x86 64-bit), you can use multi-architecture images. By default, MRSK will setup a local buildx configuration that does this through QEMU emulation. But this can be quite slow, especially on the first build.
If you want to speed up this process by using a remote AMD64 host to natively build the AMD64 part of the image, while natively building the ARM64 part locally, you can do so using builder options: If you want to speed up this process by using a remote AMD64 host to natively build the AMD64 part of the image, while natively building the ARM64 part locally, you can do so using builder options:
@@ -235,9 +415,29 @@ builder:
This is also a good option if you're running MRSK from a CI server that shares architecture with the deployment servers. This is also a good option if you're running MRSK from a CI server that shares architecture with the deployment servers.
### Using a different Dockerfile or context when building
If you need to pass a different Dockerfile or context to the build command (e.g. if you're using a monorepo or you have
different Dockerfiles), you can do so in the builder options:
```yaml
# Use a different Dockerfile
builder:
dockerfile: Dockerfile.xyz
# Set context
builder:
context: ".."
# Set Dockerfile and context
builder:
dockerfile: "../Dockerfile.xyz"
context: ".."
```
### Using build secrets for new images ### Using build secrets for new images
Some images need a secret passed in during build time, like a GITHUB_TOKEN to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration: Some images need a secret passed in during build time, like a GITHUB_TOKEN, to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration:
```yaml ```yaml
builder: builder:
@@ -247,26 +447,110 @@ builder:
This build secret can then be referenced in the Dockerfile: This build secret can then be referenced in the Dockerfile:
``` ```dockerfile
# Copy Gemfiles # Copy Gemfiles
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
# Install dependencies, including private repositories via access token # Install dependencies, including private repositories via access token (then remove bundle cache with exposed GITHUB_TOKEN)
RUN --mount=type=secret,id=GITHUB_TOKEN \ RUN --mount=type=secret,id=GITHUB_TOKEN \
BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \ BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \
bundle install bundle install && \
rm -rf /usr/local/bundle/cache
``` ```
### Using command arguments for Traefik ### Traefik command arguments
You can customize the traefik command line: Customize the Traefik command line using `args`:
```yaml ```yaml
traefik: traefik:
accesslog: true args:
accesslog.format: json accesslog: true
metrics.prometheus: true accesslog.format: json
metrics.prometheus.buckets: 0.1,0.3,1.2,5.0 ```
This starts the Traefik container with `--accesslog=true --accesslog.format=json` arguments.
### Traefik host port binding
Traefik binds to port 80 by default. Specify an alternative port using `host_port`:
```yaml
traefik:
host_port: 8080
```
### Traefik version, upgrades, and custom images
MRSK runs the traefik:v2.9 image to track Traefik 2.9.x releases.
To pin Traefik to a specific version or an image published to your registry,
specify `image`:
```yaml
traefik:
image: traefik:v2.10.0-rc1
```
This is useful for downgrading Traefik if there's an unexpected breaking
change in a minor version release, upgrading Traefik to test forthcoming
releases, or running your own Traefik-derived image.
MRSK has not been tested for compatibility with Traefik 3 betas. Please do!
### Traefik container configuration
Pass additional Docker configuration for the Traefik container using `options`:
```yaml
traefik:
options:
publish:
- 8080:8080
volumes:
- /tmp/example.json:/tmp/example.json
memory: 512m
```
This starts the Traefik container with `--volume /tmp/example.json:/tmp/example.json --publish 8080:8080 --memory 512m` arguments to `docker run`.
### Traefik container lables
Add labels to Traefik Docker container.
```yaml
traefik:
lables:
- traefik.enable: true
- traefik.http.routers.dashboard.rule: Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
- traefik.http.routers.dashboard.service: api@internal
- traefik.http.routers.dashboard.middlewares: auth
- traefik.http.middlewares.auth.basicauth.users: test:$2y$05$H2o72tMaO.TwY1wNQUV1K.fhjRgLHRDWohFvUZOJHBEtUXNKrqUKi # test:password
```
This labels Traefik container with `--label traefik.http.routers.dashboard.middlewares=\"auth\"` and so on.
### Traefik alternate entrypoints
You can configure multiple entrypoints for Traefik like so:
```yaml
service: myservice
labels:
traefik.tcp.routers.other.rule: 'HostSNI(`*`)'
traefik.tcp.routers.other.entrypoints: otherentrypoint
traefik.tcp.services.other.loadbalancer.server.port: 9000
traefik.http.routers.myservice.entrypoints: web
traefik.http.services.myservice.loadbalancer.server.port: 8080
traefik:
options:
publish:
- 9000:9000
args:
entrypoints.web.address: ':80'
entrypoints.otherentrypoint.address: ':9000'
``` ```
### Configuring build args for new images ### Configuring build args for new images
@@ -282,14 +566,13 @@ builder:
This build argument can then be used in the Dockerfile: This build argument can then be used in the Dockerfile:
``` ```
# Private repositories need an access token during the build
ARG RUBY_VERSION ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION-slim as base FROM ruby:$RUBY_VERSION-slim as base
``` ```
### Using accessories for database, cache, search services ### Using accessories for database, cache, search services
You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy: You can manage your accessory services via MRSK as well. Accessories are long-lived services that your app depends on. They are not updated when you deploy.
```yaml ```yaml
accessories: accessories:
@@ -304,32 +587,95 @@ accessories:
- MYSQL_ROOT_PASSWORD - MYSQL_ROOT_PASSWORD
volumes: volumes:
- /var/lib/mysql:/var/lib/mysql - /var/lib/mysql:/var/lib/mysql
options:
cpus: 4
memory: "2GB"
redis: redis:
image: redis:latest image: redis:latest
host: 1.1.1.4 roles:
- web
port: "36379:6379" port: "36379:6379"
volumes: volumes:
- /var/lib/redis:/data - /var/lib/redis:/data
internal-example:
image: registry.digitalocean.com/user/otherservice:latest
host: 1.1.1.5
port: 44444
```
The hosts that the accessories will run on can be specified by hosts or roles:
```yaml
# Single host
mysql:
host: 1.1.1.1
# Multiple hosts
redis:
hosts:
- 1.1.1.1
- 1.1.1.2
# By role
monitoring:
roles:
- web
- jobs
``` ```
Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `mrsk accessory` for all the commands possible. Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `mrsk accessory` for all the commands possible.
### Using a generated .env file Accessory images must be public or tagged in your private registry.
If you're using a centralized secret store, like 1Password, you can create `.env.erb` as a template which looks up the secrets. Example of a .env.erb file: ### Using Cron
```erb You can use a specific container to run your Cron jobs:
<% if (session_token = `op signin --account my-one-password-account --raw`.strip) != "" %># Generated by mrsk envify
GITHUB_TOKEN=<%= `gh config get -h github.com oauth_token`.strip %> ```yaml
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Vault/Docker Hub/password" -n --session #{session_token}` %> servers:
RAILS_MASTER_KEY=<%= `op read "op://Vault/My App/RAILS_MASTER_SECRET" -n --session #{session_token}` %> cron:
MYSQL_ROOT_PASSWORD=<%= `op read "op://Vault/My App/MYSQL_ROOT_PASSWORD" -n --session #{session_token}` %> hosts:
<% else raise ArgumentError, "Session token missing" end %> - 192.168.0.1
cmd:
bash -c "cat config/crontab | crontab - && cron -f"
``` ```
This template can safely be checked into git. Then everyone deploying the app can run `mrsk envify` when they setup the app for the first time or passwords change to get the correct `.env` file. This assumes the Cron settings are stored in `config/crontab`.
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`. ### Using audit broadcasts
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument:
```yaml
audit_broadcast_cmd:
bin/audit_broadcast
```
The broadcast command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like follows to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
### Custom healthcheck
MRSK defaults to checking the health of your application again `/up` on port 3000 up to 7 times. You can tailor the behaviour with the `healthcheck` setting:
```yaml
healthcheck:
path: /healthz
port: 4000
max_attempts: 7
```
This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
The healthcheck also allows for an optional `max_attempts` setting, which will attempt the healthcheck up to the specified number of times before failing the deploy. This is useful for applications that take a while to start up. The default is 7.
## Commands ## Commands
@@ -398,7 +744,7 @@ mrsk app exec -i 'bin/rails console'
``` ```
### Running details to see state of containers ### Running details to show state of containers
You can see the state of your servers by running `mrsk details`: You can see the state of your servers by running `mrsk details`:
@@ -446,9 +792,33 @@ Note that by default old containers are pruned after 3 days when you run `mrsk d
If you wish to remove the entire application, including Traefik, containers, images, and registry session, you can run `mrsk remove`. This will leave the servers clean. If you wish to remove the entire application, including Traefik, containers, images, and registry session, you can run `mrsk remove`. This will leave the servers clean.
## Locking
Commands that are unsafe to run concurrently will take a deploy lock while they run. The lock is the `mrsk_lock` directory on the primary server.
You can check the lock status with:
```
mrsk lock status
Locked by: AN Other at 2023-03-24 09:49:03 UTC
Version: 77f45c0686811c68989d6576748475a60bf53fc2
Message: Automatic deploy lock
```
You can also manually acquire and release the lock
```
mrsk lock acquire -m "Doing maintanence"
```
```
mrsk lock release
```
## Stage of development ## Stage of development
This is alpha software. Lots of stuff is missing. Lots of stuff will keep moving around for a while. This is beta software. Commands may still move around. But we're live in production at [37signals](https://37signals.com).
## License ## License

View File

@@ -10,7 +10,9 @@ begin
rescue SSHKit::Runner::ExecuteError => e rescue SSHKit::Runner::ExecuteError => e
puts " \e[31mERROR (#{e.cause.class}): #{e.cause.message}\e[0m" puts " \e[31mERROR (#{e.cause.class}): #{e.cause.message}\e[0m"
puts e.cause.backtrace if ENV["VERBOSE"] puts e.cause.backtrace if ENV["VERBOSE"]
exit 1
rescue => e rescue => e
puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
puts e.backtrace if ENV["VERBOSE"] puts e.backtrace if ENV["VERBOSE"]
exit 1
end end

View File

@@ -0,0 +1,9 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
git "https://github.com/rails/rails.git" do
gem "railties"
gem "activesupport"
end
gemspec path: "../"

View File

@@ -1,6 +1,7 @@
module Mrsk module Mrsk
end end
require "active_support"
require "zeitwerk" require "zeitwerk"
loader = Zeitwerk::Loader.for_gem loader = Zeitwerk::Loader.for_gem

View File

@@ -1,4 +1,5 @@
module Mrsk::Cli module Mrsk::Cli
class LockError < StandardError; end
end end
# SSHKit uses instance eval, so we need a global const for ergonomics # SSHKit uses instance eval, so we need a global const for ergonomics

View File

@@ -1,100 +1,113 @@
class Mrsk::Cli::Accessory < Mrsk::Cli::Base class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)" desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
def boot(name) def boot(name)
if name == "all" with_lock do
MRSK.accessory_names.each { |accessory_name| boot(accessory_name) } if name == "all"
else MRSK.accessory_names.each { |accessory_name| boot(accessory_name) }
with_accessory(name) do |accessory| else
directories(name) with_accessory(name) do |accessory|
upload(name) directories(name)
upload(name)
on(accessory.host) do on(accessory.hosts) do
execute *MRSK.auditor.record("accessory #{name} boot"), verbosity: :debug execute *MRSK.registry.login
execute *accessory.run execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}" unless options[:skip_broadcast]
end end
end end
end end
end end
desc "upload [NAME]", "Upload accessory files to host" desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name) def upload(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} upload files"), verbosity: :debug on(accessory.hosts) do
accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local)
accessory.files.each do |(local, remote)| execute *accessory.make_directory_for(remote)
accessory.ensure_local_file_present(local) upload! local, remote
execute :chmod, "755", remote
execute *accessory.make_directory_for(remote) end
upload! local, remote
execute :chmod, "755", remote
end end
end end
end end
end end
desc "directories [NAME]", "Create accessory directories on host" desc "directories [NAME]", "Create accessory directories on host", hide: true
def directories(name) def directories(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} create directories"), verbosity: :debug on(accessory.hosts) do
accessory.directories.keys.each do |host_path|
accessory.directories.keys.each do |host_path| execute *accessory.make_directory(host_path)
execute *accessory.make_directory(host_path) end
end end
end end
end end
end end
desc "reboot [NAME]", "Reboot accessory on host (stop container, remove container, start new container)" desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container)"
def reboot(name) def reboot(name)
with_accessory(name) do |accessory| with_lock do
stop(name) with_accessory(name) do |accessory|
remove_container(name) stop(name)
boot(name) remove_container(name)
boot(name)
end
end end
end end
desc "start [NAME]", "Start existing accessory on host" desc "start [NAME]", "Start existing accessory container on host"
def start(name) def start(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} start"), verbosity: :debug on(accessory.hosts) do
execute *accessory.start execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start
end
end end
end end
end end
desc "stop [NAME]", "Stop accessory on host" desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name) def stop(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} stop"), verbosity: :debug on(accessory.hosts) do
execute *accessory.stop, raise_on_non_zero_exit: false execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
end end
end end
end end
desc "restart [NAME]", "Restart accessory on host" desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name) def restart(name)
with_accessory(name) do with_lock do
stop(name) with_accessory(name) do
start(name) stop(name)
start(name)
end
end end
end end
desc "details [NAME]", "Display details about accessory on host (use NAME=all to boot all accessories)" desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)"
def details(name) def details(name)
if name == "all" if name == "all"
MRSK.accessory_names.each { |accessory_name| details(accessory_name) } MRSK.accessory_names.each { |accessory_name| details(accessory_name) }
else else
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { puts capture_with_info(*accessory.info) } on(accessory.hosts) { puts capture_with_info(*accessory.info) }
end end
end end
end end
desc "exec [NAME] [CMD]", "Execute a custom command on servers" desc "exec [NAME] [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(name, cmd) def exec(name, cmd)
@@ -110,22 +123,22 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
when options[:reuse] when options[:reuse]
say "Launching command from existing container...", :magenta say "Launching command from existing container...", :magenta
on(accessory.host) do on(accessory.hosts) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd)) capture_with_info(*accessory.execute_in_existing_container(cmd))
end end
else else
say "Launching command from new container...", :magenta say "Launching command from new container...", :magenta
on(accessory.host) do on(accessory.hosts) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd)) capture_with_info(*accessory.execute_in_new_container(cmd))
end end
end end
end end
end end
desc "logs [NAME]", "Show log lines from accessory on host" desc "logs [NAME]", "Show log lines from accessory on host (use --help to show options)"
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
@@ -136,7 +149,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
if options[:follow] if options[:follow]
run_locally do run_locally do
info "Following logs on #{accessory.host}..." info "Following logs on #{accessory.hosts}..."
info accessory.follow_logs(grep: grep) info accessory.follow_logs(grep: grep)
exec accessory.follow_logs(grep: grep) exec accessory.follow_logs(grep: grep)
end end
@@ -144,53 +157,63 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
since = options[:since] since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(accessory.host) do on(accessory.hosts) do
puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep)) puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
end end
end end
end end
end end
desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to boot all accessories)" desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove(name) def remove(name)
if name == "all" with_lock do
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) } if name == "all"
else MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
with_accessory(name) do else
stop(name) if options[:confirmed] || ask("This will remove all containers, images and data directories for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
remove_container(name) with_accessory(name) do
remove_image(name) stop(name)
remove_service_directory(name) remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end end
end end
end end
desc "remove_container [NAME]", "Remove accessory container from host" desc "remove_container [NAME]", "Remove accessory container from host", hide: true
def remove_container(name) def remove_container(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} remove container"), verbosity: :debug on(accessory.hosts) do
execute *accessory.remove_container execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container
end
end end
end end
end end
desc "remove_image [NAME]", "Remove accessory image from host" desc "remove_image [NAME]", "Remove accessory image from host", hide: true
def remove_image(name) def remove_image(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} remove image"), verbosity: :debug on(accessory.hosts) do
execute *accessory.remove_image execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug
execute *accessory.remove_image
end
end end
end end
end end
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host" desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true
def remove_service_directory(name) def remove_service_directory(name)
with_accessory(name) do |accessory| with_lock do
on(accessory.host) do with_accessory(name) do |accessory|
execute *MRSK.auditor.record("accessory #{name} remove service directory"), verbosity: :debug on(accessory.hosts) do
execute *accessory.remove_service_directory execute *accessory.remove_service_directory
end
end end
end end
end end

View File

@@ -1,26 +1,31 @@
class Mrsk::Cli::App < Mrsk::Cli::Base class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)" desc "boot", "Boot app on servers (or reboot app if already running)"
def boot def boot
say "Get most recent version available as an image...", :magenta unless options[:version] with_lock do
using_version(options[:version] || most_recent_version_available) do |version| say "Get most recent version available as an image...", :magenta unless options[:version]
say "Start container with version #{version} (or reboot if already running)...", :magenta using_version(version_or_latest) do |version|
say "Start container with version #{version} using a #{MRSK.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
MRSK.config.roles.each do |role| cli = self
on(role.hosts) do |host|
execute *MRSK.auditor.record("app boot version #{version}"), verbosity: :debug
begin on(MRSK.hosts) do |host|
execute *MRSK.app.stop, raise_on_non_zero_exit: false roles = MRSK.roles_on(host)
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Rebooting container with same version already deployed on #{host}"
execute *MRSK.auditor.record("app rebooted with version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version) roles.each do |role|
execute *MRSK.app.run(role: role.name) execute *MRSK.auditor(role: role).record("Booted app version #{version}"), verbosity: :debug
else
raise begin
if capture_with_info(*MRSK.app(role: role).container_id_for_version(version)).present?
tmp_version = "#{version}_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
execute *MRSK.auditor(role: role).record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
execute *MRSK.app(role: role).rename_container(version: version, new_version: tmp_version)
end
old_version = capture_with_info(*MRSK.app(role: role).current_running_version).strip
execute *MRSK.app(role: role).run
sleep MRSK.config.readiness_delay
execute *MRSK.app(role: role).stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
end end
end end
end end
@@ -28,28 +33,47 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
end end
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)" desc "start", "Start existing app container on servers"
def start def start
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("app start version #{MRSK.version}"), verbosity: :debug on(MRSK.hosts) do |host|
execute *MRSK.app.start, raise_on_non_zero_exit: false roles = MRSK.roles_on(host)
roles.each do |role|
execute *MRSK.auditor.record("Started app version #{MRSK.config.version}"), verbosity: :debug
execute *MRSK.app(role: role).start, raise_on_non_zero_exit: false
end
end
end end
end end
desc "stop", "Stop app on servers" desc "stop", "Stop app container on servers"
def stop def stop
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("app stop"), verbosity: :debug on(MRSK.hosts) do |host|
execute *MRSK.app.stop, raise_on_non_zero_exit: false roles = MRSK.roles_on(host)
roles.each do |role|
execute *MRSK.auditor(role: role).record("Stopped app"), verbosity: :debug
execute *MRSK.app(role: role).stop, raise_on_non_zero_exit: false
end
end
end end
end end
desc "details", "Display details about app containers" # FIXME: Drop in favor of just containers?
desc "details", "Show details about app containers"
def details def details
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) } on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host)
roles.each do |role|
puts_by_host host, capture_with_info(*MRSK.app(role: role).info)
end
end
end end
desc "exec [CMD]", "Execute a custom command on servers" desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(cmd) def exec(cmd)
@@ -58,12 +82,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
say "Get current version of running container...", :magenta unless options[:version] say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version| using_version(options[:version] || current_running_version) do |version|
say "Launching interactive command with version #{version} via SSH from existing container on #{MRSK.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from existing container on #{MRSK.primary_host}...", :magenta
run_locally { exec MRSK.app.execute_in_existing_container_over_ssh(cmd, host: MRSK.primary_host) } run_locally { exec MRSK.app(role: "web").execute_in_existing_container_over_ssh(cmd, host: MRSK.primary_host) }
end end
when options[:interactive] when options[:interactive]
say "Get most recent version available as an image...", :magenta unless options[:version] say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(options[:version] || most_recent_version_available) do |version| using_version(version_or_latest) do |version|
say "Launching interactive command with version #{version} via SSH from new container on #{MRSK.primary_host}...", :magenta say "Launching interactive command with version #{version} via SSH from new container on #{MRSK.primary_host}...", :magenta
run_locally { exec MRSK.app.execute_in_new_container_over_ssh(cmd, host: MRSK.primary_host) } run_locally { exec MRSK.app.execute_in_new_container_over_ssh(cmd, host: MRSK.primary_host) }
end end
@@ -74,38 +98,42 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
say "Launching command with version #{version} from existing container...", :magenta say "Launching command with version #{version} from existing container...", :magenta
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug roles = MRSK.roles_on(host)
puts_by_host host, capture_with_info(*MRSK.app.execute_in_existing_container(cmd))
roles.each do |role|
execute *MRSK.auditor(role: role).record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app(role: role).execute_in_existing_container(cmd))
end
end end
end end
else else
say "Get most recent version available as an image...", :magenta unless options[:version] say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(options[:version] || most_recent_version_available) do |version| using_version(version_or_latest) do |version|
say "Launching command with version #{version} from new container...", :magenta say "Launching command with version #{version} from new container...", :magenta
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd)) puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd))
end end
end end
end end
end end
desc "containers", "List all the app containers currently on servers" desc "containers", "Show app containers on servers"
def containers def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end end
desc "images", "List all the app images currently on servers" desc "images", "Show app images on servers"
def images def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end end
desc "logs", "Show lines from app on servers" desc "logs", "Show log lines from app on servers (use --help to show options)"
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
def logs def logs
# FIXME: Catch when app containers aren't running # FIXME: Catch when app containers aren't running
@@ -114,18 +142,26 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
if options[:follow] if options[:follow]
run_locally do run_locally do
info "Following logs on #{MRSK.primary_host}..." info "Following logs on #{MRSK.primary_host}..."
info MRSK.app.follow_logs(host: MRSK.primary_host, grep: grep)
exec MRSK.app.follow_logs(host: MRSK.primary_host, grep: grep) MRSK.specific_roles ||= ["web"]
role = MRSK.roles_on(MRSK.primary_host).first
info MRSK.app(role: role).follow_logs(host: MRSK.primary_host, grep: grep)
exec MRSK.app(role: role).follow_logs(host: MRSK.primary_host, grep: grep)
end end
else else
since = options[:since] since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
begin roles = MRSK.roles_on(host)
puts_by_host host, capture_with_info(*MRSK.app.logs(since: since, lines: lines, grep: grep))
rescue SSHKit::Command::Failed roles.each do |role|
puts_by_host host, "Nothing found" begin
puts_by_host host, capture_with_info(*MRSK.app(role: role).logs(since: since, lines: lines, grep: grep))
rescue SSHKit::Command::Failed
puts_by_host host, "Nothing found"
end
end end
end end
end end
@@ -133,36 +169,53 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove", "Remove app containers and images from servers" desc "remove", "Remove app containers and images from servers"
def remove def remove
remove_containers with_lock do
remove_images stop
remove_containers
remove_images
end
end end
desc "remove_container [VERSION]", "Remove app container with given version from servers" desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version) def remove_container(version)
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("app remove container #{version}"), verbosity: :debug on(MRSK.hosts) do |host|
execute *MRSK.app.remove_container(version: version) roles = MRSK.roles_on(host)
roles.each do |role|
execute *MRSK.auditor(role: role).record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.app(role: role).remove_container(version: version)
end
end
end end
end end
desc "remove_containers", "Remove all app containers from servers" desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers def remove_containers
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("app remove containers"), verbosity: :debug on(MRSK.hosts) do |host|
execute *MRSK.app.remove_containers roles = MRSK.roles_on(host)
roles.each do |role|
execute *MRSK.auditor(role: role).record("Removed all app containers"), verbosity: :debug
execute *MRSK.app(role: role).remove_containers
end
end
end end
end end
desc "remove_images", "Remove all app images from servers" desc "remove_images", "Remove all app images from servers", hide: true
def remove_images def remove_images
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("app remove images"), verbosity: :debug on(MRSK.hosts) do
execute *MRSK.app.remove_images execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images
end
end end
end end
desc "current_version", "Shows the version currently running" desc "version", "Show app version currently running on servers"
def current_version def version
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip }
end end
@@ -181,15 +234,13 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
end end
def most_recent_version_available(host: MRSK.primary_host)
version = nil
on(host) { version = capture_with_info(*MRSK.app.most_recent_version_from_available_images).strip }
version.presence
end
def current_running_version(host: MRSK.primary_host) def current_running_version(host: MRSK.primary_host)
version = nil version = nil
on(host) { version = capture_with_info(*MRSK.app.current_running_version).strip } on(host) { version = capture_with_info(*MRSK.app.current_running_version).strip }
version.presence version.presence
end end
def version_or_latest
options[:version] || "latest"
end
end end

View File

@@ -17,13 +17,15 @@ module Mrsk::Cli
class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)" class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)"
class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)" class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)"
class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file (default: config/deploy.yml)" class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (west -> deploy.west.yml)" class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
class_option :skip_broadcast, aliases: "-B", type: :boolean, default: false, desc: "Skip audit broadcasts"
def initialize(*) def initialize(*)
super super
load_envs load_envs
initialize_commander(options) initialize_commander(options_with_subcommand_class_options)
end end
private private
@@ -35,16 +37,12 @@ module Mrsk::Cli
end end
end end
def options_with_subcommand_class_options
options.merge(@_initializer.last[:class_options] || {})
end
def initialize_commander(options) def initialize_commander(options)
MRSK.tap do |commander| MRSK.tap do |commander|
commander.config_file = Pathname.new(File.expand_path(options[:config_file]))
commander.destination = options[:destination]
commander.version = options[:version]
commander.specific_hosts = options[:hosts]&.split(",")
commander.specific_roles = options[:roles]&.split(",")
commander.specific_primary! if options[:primary]
if options[:verbose] if options[:verbose]
ENV["VERBOSE"] = "1" # For backtraces via cli/start ENV["VERBOSE"] = "1" # For backtraces via cli/start
commander.verbosity = :debug commander.verbosity = :debug
@@ -53,15 +51,63 @@ module Mrsk::Cli
if options[:quiet] if options[:quiet]
commander.verbosity = :error commander.verbosity = :error
end end
commander.configure \
config_file: Pathname.new(File.expand_path(options[:config_file])),
destination: options[:destination],
version: options[:version]
commander.specific_hosts = options[:hosts]&.split(",")
commander.specific_roles = options[:roles]&.split(",")
commander.specific_primary! if options[:primary]
end end
end end
def print_runtime def print_runtime
started_at = Time.now started_at = Time.now
yield yield
return Time.now - started_at
ensure ensure
runtime = Time.now - started_at runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}" puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
def with_lock
acquire_lock
yield
release_lock
rescue
error " \e[31mDeploy lock was not released\e[0m" if MRSK.lock_count > 0
raise
end
def acquire_lock
if MRSK.lock_count == 0
say "Acquiring the deploy lock"
on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version) }
end
MRSK.lock_count += 1
rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /cannot create directory/
invoke "mrsk:cli:lock:status", []
raise LockError, "Deploy lock found"
else
raise e
end
end
def release_lock
MRSK.lock_count -= 1
if MRSK.lock_count == 0
say "Releasing the deploy lock"
on(MRSK.primary_host) { execute *MRSK.lock.release }
end
end
end end
end end

View File

@@ -1,65 +1,76 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Deliver a newly built app image to servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
invoke :push with_lock do
invoke :pull push
pull
end
end end
desc "push", "Build locally and push app image to registry" desc "push", "Build and push app image to registry"
def push def push
cli = self with_lock do
cli = self
run_locally do run_locally do
begin begin
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/ if e.message =~ /(no builder)|(no such file or directory)/
error "Missing compatible builder, so creating a new one first" error "Missing compatible builder, so creating a new one first"
if cli.create if cli.create
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
end
else
raise
end end
else
raise
end end
end end
end end
end end
desc "pull", "Pull app image from the registry onto servers" desc "pull", "Pull app image from registry onto servers"
def pull def pull
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("build pull image #{MRSK.version}"), verbosity: :debug on(MRSK.hosts) do
execute *MRSK.builder.pull execute *MRSK.auditor.record("Pulled image with version #{MRSK.config.version}"), verbosity: :debug
execute *MRSK.builder.clean, raise_on_non_zero_exit: false
execute *MRSK.builder.pull
end
end end
end end
desc "create", "Create a local build setup" desc "create", "Create a build setup"
def create def create
run_locally do with_lock do
begin run_locally do
debug "Using builder: #{MRSK.builder.name}" begin
execute *MRSK.builder.create debug "Using builder: #{MRSK.builder.name}"
rescue SSHKit::Command::Failed => e execute *MRSK.builder.create
if e.message =~ /stderr=(.*)/ rescue SSHKit::Command::Failed => e
error "Couldn't create remote builder: #{$1}" if e.message =~ /stderr=(.*)/
false error "Couldn't create remote builder: #{$1}"
else false
raise else
raise
end
end end
end end
end end
end end
desc "remove", "Remove local build setup" desc "remove", "Remove build setup"
def remove def remove
run_locally do with_lock do
debug "Using builder: #{MRSK.builder.name}" run_locally do
execute *MRSK.builder.remove debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end end
end end
desc "details", "Show the name of the configured builder" desc "details", "Show build setup"
def details def details
run_locally do run_locally do
puts "Builder: #{MRSK.builder.name}" puts "Builder: #{MRSK.builder.name}"

View File

@@ -0,0 +1,50 @@
class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base
class HealthcheckError < StandardError; end
default_command :perform
desc "perform", "Health check current app version"
def perform
on(MRSK.primary_host) do
begin
execute *MRSK.healthcheck.run
target = "Health check against #{MRSK.config.healthcheck["path"]}"
attempt = 1
max_attempts = MRSK.config.healthcheck["max_attempts"]
begin
status = capture_with_info(*MRSK.healthcheck.curl)
if status == "200"
info "#{target} succeeded with 200 OK!"
else
raise HealthcheckError, "#{target} failed with status #{status}"
end
rescue SSHKit::Command::Failed
if attempt <= max_attempts
info "#{target} failed to respond, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..."
sleep attempt
attempt += 1
retry
else
raise
end
end
rescue SSHKit::Command::Failed, HealthcheckError => e
error capture_with_info(*MRSK.healthcheck.logs)
if e.message =~ /curl/
raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!"
else
raise
end
ensure
execute *MRSK.healthcheck.stop, raise_on_non_zero_exit: false
execute *MRSK.healthcheck.remove, raise_on_non_zero_exit: false
end
end
end
end

37
lib/mrsk/cli/lock.rb Normal file
View File

@@ -0,0 +1,37 @@
class Mrsk::Cli::Lock < Mrsk::Cli::Base
desc "status", "Report lock status"
def status
handle_missing_lock do
on(MRSK.primary_host) { puts capture_with_info(*MRSK.lock.status) }
end
end
desc "acquire", "Acquire the deploy lock"
option :message, aliases: "-m", type: :string, desc: "A lock mesasge", required: true
def acquire
message = options[:message]
handle_missing_lock do
on(MRSK.primary_host) { execute *MRSK.lock.acquire(message, MRSK.config.version) }
say "Acquired the deploy lock"
end
end
desc "release", "Release the deploy lock"
def release
handle_missing_lock do
on(MRSK.primary_host) { execute *MRSK.lock.release }
say "Released the deploy lock"
end
end
private
def handle_missing_lock
yield
rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /No such file or directory/
say "There is no deploy lock"
else
raise
end
end
end

View File

@@ -1,59 +1,113 @@
class Mrsk::Cli::Main < Mrsk::Cli::Base class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy the app to servers" desc "setup", "Setup all accessories and deploy app to servers"
def setup def setup
print_runtime do with_lock do
invoke "mrsk:cli:server:bootstrap" print_runtime do
invoke "mrsk:cli:accessory:boot", [ "all" ] invoke "mrsk:cli:server:bootstrap"
deploy invoke "mrsk:cli:accessory:boot", [ "all" ]
deploy
end
end end
end end
desc "deploy", "Deploy the app to servers" desc "deploy", "Deploy app to servers"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy def deploy
print_runtime do with_lock do
say "Ensure Docker is installed...", :magenta invoke_options = deploy_options
invoke "mrsk:cli:server:bootstrap"
say "Log into image registry...", :magenta runtime = print_runtime do
invoke "mrsk:cli:registry:login" say "Ensure curl and Docker are installed...", :magenta
invoke "mrsk:cli:server:bootstrap", [], invoke_options
say "Build and push app image...", :magenta say "Log into image registry...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:registry:login", [], invoke_options
say "Ensure Traefik is running...", :magenta if options[:skip_push]
invoke "mrsk:cli:traefik:boot" say "Pull app image...", :magenta
invoke "mrsk:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver", [], invoke_options
end
invoke "mrsk:cli:app:boot" say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot", [], invoke_options
say "Prune old containers and images...", :magenta say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:prune:all" invoke "mrsk:cli:healthcheck:perform", [], invoke_options
invoke "mrsk:cli:app:boot", [], invoke_options
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all", [], invoke_options
end
audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end end
end end
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)" desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy def redeploy
print_runtime do with_lock do
say "Build and push app image...", :magenta invoke_options = deploy_options
invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:app:boot" runtime = print_runtime do
if options[:skip_push]
say "Pull app image...", :magenta
invoke "mrsk:cli:build:pull", [], invoke_options
else
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver", [], invoke_options
end
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform", [], invoke_options
invoke "mrsk:cli:app:boot", [], invoke_options
end
audit_broadcast "Redeployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end end
end end
desc "rollback [VERSION]", "Rollback the app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version) def rollback(version)
MRSK.version = version with_lock do
MRSK.config.version = version
cli = self if container_available?(version)
say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
cli.say "Stop current version, then start version #{version}...", :magenta cli = self
on(MRSK.hosts) do old_version = nil
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host)
roles.each do |role|
app = MRSK.app(role: role)
old_version = capture_with_info(*app.current_running_version).strip.presence
execute *app.start
if old_version
sleep MRSK.config.readiness_delay
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
end
end
end
audit_broadcast "Rolled back #{service_version(Mrsk::Utils.abbreviate_version(old_version))} to #{service_version}" unless options[:skip_broadcast]
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end
end end
end end
desc "details", "Display details about Traefik and app containers" desc "details", "Show details about all containers"
def details def details
invoke "mrsk:cli:traefik:details" invoke "mrsk:cli:traefik:details"
invoke "mrsk:cli:app:details" invoke "mrsk:cli:app:details"
@@ -67,10 +121,10 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end end
end end
desc "config", "Show combined config" desc "config", "Show combined config (including secrets!)"
def config def config
run_locally do run_locally do
puts MRSK.config.to_h.to_yaml puts Mrsk::Utils.redacted(MRSK.config.to_h).to_yaml
end end
end end
@@ -97,8 +151,10 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
puts "Binstub already exists in bin/mrsk (remove first to create a new one)" puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else else
puts "Adding MRSK to Gemfile and bundle..." puts "Adding MRSK to Gemfile and bundle..."
`bundle add mrsk` run_locally do
`bundle binstubs mrsk` execute :bundle, :add, :mrsk
execute :bundle, :binstubs, :mrsk
end
puts "Created binstub file in bin/mrsk" puts "Created binstub file in bin/mrsk"
end end
end end
@@ -107,42 +163,78 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)" desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
def envify def envify
if destination = options[:destination] if destination = options[:destination]
File.write(".env.#{destination}", ERB.new(IO.read(Pathname.new(File.expand_path(".env.#{destination}.erb")))).result) env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else else
File.write(".env", ERB.new(IO.read(Pathname.new(File.expand_path(".env.erb")))).result) env_template_path = ".env.erb"
env_path = ".env"
end
File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove
with_lock do
if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
invoke "mrsk:cli:accessory:remove", [ "all" ], options
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed)
end
end end
end end
desc "remove", "Remove Traefik, app, and registry session from servers" desc "version", "Show MRSK version"
def remove
invoke "mrsk:cli:traefik:remove"
invoke "mrsk:cli:app:remove"
invoke "mrsk:cli:registry:logout"
end
desc "version", "Display the MRSK version"
def version def version
puts Mrsk::VERSION puts Mrsk::VERSION
end end
desc "accessory", "Manage the accessories" desc "accessory", "Manage accessories (db/redis/search)"
subcommand "accessory", Mrsk::Cli::Accessory subcommand "accessory", Mrsk::Cli::Accessory
desc "app", "Manage the application" desc "app", "Manage application"
subcommand "app", Mrsk::Cli::App subcommand "app", Mrsk::Cli::App
desc "build", "Build the application image" desc "build", "Build application image"
subcommand "build", Mrsk::Cli::Build subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "prune", "Prune old application images and containers" desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune subcommand "prune", Mrsk::Cli::Prune
desc "registry", "Login and out of the image registry" desc "registry", "Login and -out of the image registry"
subcommand "registry", Mrsk::Cli::Registry subcommand "registry", Mrsk::Cli::Registry
desc "server", "Bootstrap servers with Docker" desc "server", "Bootstrap servers with curl and Docker"
subcommand "server", Mrsk::Cli::Server subcommand "server", Mrsk::Cli::Server
desc "traefik", "Manage the Traefik load balancer" desc "traefik", "Manage Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik subcommand "traefik", Mrsk::Cli::Traefik
desc "lock", "Manage the deploy lock"
subcommand "lock", Mrsk::Cli::Lock
private
def container_available?(version, host: MRSK.primary_host)
available = nil
on(host) do
first_role = MRSK.roles_on(host).first
available = capture_with_info(*MRSK.app(role: first_role).container_id_for_version(version)).present?
end
available
end
def deploy_options
{ "version" => MRSK.config.version }.merge(options.without("skip_push"))
end
def service_version(version = MRSK.config.abbreviated_version)
[ MRSK.config.service, version ].compact.join("@")
end
end end

View File

@@ -1,23 +1,29 @@
class Mrsk::Cli::Prune < Mrsk::Cli::Base class Mrsk::Cli::Prune < Mrsk::Cli::Base
desc "all", "Prune unused images and stopped containers" desc "all", "Prune unused images and stopped containers"
def all def all
invoke :containers with_lock do
invoke :images containers
end images
desc "images", "Prune unused images older than 30 days"
def images
on(MRSK.hosts) do
execute *MRSK.auditor.record("prune images"), verbosity: :debug
execute *MRSK.prune.images
end end
end end
desc "containers", "Prune stopped containers for the service older than 3 days" desc "images", "Prune unused images older than 7 days"
def images
with_lock do
on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned images"), verbosity: :debug
execute *MRSK.prune.images
end
end
end
desc "containers", "Prune stopped containers older than 3 days"
def containers def containers
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("prune containers"), verbosity: :debug on(MRSK.hosts) do
execute *MRSK.prune.containers execute *MRSK.auditor.record("Pruned containers"), verbosity: :debug
execute *MRSK.prune.containers
end
end end
end end
end end

View File

@@ -1,15 +1,17 @@
class Mrsk::Cli::Registry < Mrsk::Cli::Base class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Login to the registry locally and remotely" desc "login", "Log in to registry locally and remotely"
def login def login
run_locally { execute *MRSK.registry.login } run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login } on(MRSK.hosts) { execute *MRSK.registry.login }
# FIXME: This rescue needed?
rescue ArgumentError => e rescue ArgumentError => e
puts e.message puts e.message
end end
desc "logout", "Logout of the registry remotely" desc "logout", "Log out of registry remotely"
def logout def logout
on(MRSK.hosts) { execute *MRSK.registry.logout } on(MRSK.hosts) { execute *MRSK.registry.logout }
# FIXME: This rescue needed?
rescue ArgumentError => e rescue ArgumentError => e
puts e.message puts e.message
end end

View File

@@ -1,6 +1,17 @@
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure Docker is installed on the servers" desc "bootstrap", "Ensure curl and Docker are installed on servers"
def bootstrap def bootstrap
on(MRSK.hosts + MRSK.accessory_hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" } with_lock do
on(MRSK.hosts + MRSK.accessory_hosts) do
dependencies_to_install = Array.new.tap do |dependencies|
dependencies << "curl" unless execute "which curl", raise_on_non_zero_exit: false
dependencies << "docker.io" unless execute "which docker", raise_on_non_zero_exit: false
end
if dependencies_to_install.any?
execute "apt-get update -y && apt-get install #{dependencies_to_install.join(" ")} -y"
end
end
end
end end
end end

View File

@@ -1,5 +1,4 @@
# Name of your application. Used to uniquely configuring Traefik and app containers. # Name of your application. Used to uniquely configure containers.
# Your Dockerfile should set LABEL service=the-same-value to ensure image pruning works.
service: my-app service: my-app
# Name of the container image. # Name of the container image.
@@ -14,4 +13,66 @@ registry:
# Specify the registry server, if you're not using Docker Hub # Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ... # server: registry.digitalocean.com / ghcr.io / ...
username: my-user username: my-user
password: my-password-should-go-somewhere-safe
# Always use an access token rather than real password when possible.
password:
- MRSK_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .env).
# env:
# clear:
# DB_HOST: 192.168.0.2
# secret:
# - RAILS_MASTER_KEY
# Call a broadcast command on deploys.
# audit_broadcast_cmd:
# bin/broadcast_to_bc
# Use a different ssh user than root
# ssh:
# user: app
# Configure builder setup.
# builder:
# args:
# RUBY_VERSION: 3.2.0
# secrets:
# - GITHUB_TOKEN
# remote:
# arch: amd64
# host: ssh://app@192.168.0.1
# Use accessory services (secrets come from .env).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# port: 3306
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql.erb:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: redis:7.0
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data
# Configure custom arguments for Traefik
# traefik:
# args:
# accesslog: true
# accesslog.format: json
# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
# path: /healthz
# port: 4000

View File

@@ -1,39 +1,52 @@
class Mrsk::Cli::Traefik < Mrsk::Cli::Base class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "boot", "Boot Traefik on servers" desc "boot", "Boot Traefik on servers"
def boot def boot
on(MRSK.traefik_hosts) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false } with_lock do
on(MRSK.traefik_hosts) do
execute *MRSK.registry.login
execute *MRSK.traefik.run, raise_on_non_zero_exit: false
end
end
end end
desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)" desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)"
def reboot def reboot
invoke :stop with_lock do
invoke :remove_container stop
invoke :boot remove_container
boot
end
end end
desc "start", "Start existing Traefik on servers" desc "start", "Start existing Traefik container on servers"
def start def start
on(MRSK.traefik_hosts) do with_lock do
execute *MRSK.auditor.record("traefik start"), verbosity: :debug on(MRSK.traefik_hosts) do
execute *MRSK.traefik.start, raise_on_non_zero_exit: false execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false
end
end end
end end
desc "stop", "Stop Traefik on servers" desc "stop", "Stop existing Traefik container on servers"
def stop def stop
on(MRSK.traefik_hosts) do with_lock do
execute *MRSK.auditor.record("traefik stop"), verbosity: :debug on(MRSK.traefik_hosts) do
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
end
end end
end end
desc "restart", "Restart Traefik on servers" desc "restart", "Restart existing Traefik container on servers"
def restart def restart
invoke :stop with_lock do
invoke :start stop
start
end
end end
desc "details", "Display details about Traefik containers from servers" desc "details", "Show details about Traefik container from servers"
def details def details
on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" } on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" }
end end
@@ -64,24 +77,30 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove", "Remove Traefik container and image from servers" desc "remove", "Remove Traefik container and image from servers"
def remove def remove
invoke :stop with_lock do
invoke :remove_container stop
invoke :remove_image remove_container
end remove_image
desc "remove_container", "Remove Traefik container from servers"
def remove_container
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end end
end end
desc "remove_container", "Remove Traefik image from servers" desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container
with_lock do
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end
end
desc "remove_container", "Remove Traefik image from servers", hide: true
def remove_image def remove_image
on(MRSK.traefik_hosts) do with_lock do
execute *MRSK.auditor.record("traefik remove image"), verbosity: :debug on(MRSK.traefik_hosts) do
execute *MRSK.traefik.remove_image execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end end
end end
end end

View File

@@ -1,35 +1,57 @@
require "active_support/core_ext/enumerable" require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation"
class Mrsk::Commander class Mrsk::Commander
attr_accessor :config_file, :destination, :verbosity, :version attr_accessor :verbosity, :lock_count
def initialize(config_file: nil, destination: nil, verbosity: :info) def initialize
@config_file, @destination, @verbosity = config_file, destination, verbosity self.verbosity = :info
self.lock_count = 0
end end
def config def config
@config ||= \ @config ||= Mrsk::Configuration.create_from(**@config_kwargs).tap do |config|
Mrsk::Configuration @config_kwargs = nil
.create_from(config_file, destination: destination, version: cascading_version) configure_sshkit_with(config)
.tap { |config| configure_sshkit_with(config) } end
end end
attr_accessor :specific_hosts def configure(**kwargs)
@config, @config_kwargs = nil, kwargs
end
attr_reader :specific_roles, :specific_hosts
def specific_primary! def specific_primary!
self.specific_hosts = [ config.primary_web_host ] self.specific_hosts = [ config.primary_web_host ]
end end
def specific_roles=(role_names) def specific_roles=(role_names)
self.specific_hosts = config.roles.select { |r| role_names.include?(r.name) }.flat_map(&:hosts) if role_names.present? @specific_roles = config.roles.select { |r| role_names.include?(r.name) } if role_names.present?
end
def specific_hosts=(hosts)
@specific_hosts = config.all_hosts & hosts if hosts.present?
end end
def primary_host def primary_host
specific_hosts&.sole || config.primary_web_host specific_hosts&.first || config.primary_web_host
end
def roles
(specific_roles || config.roles).select do |role|
((specific_hosts || config.all_hosts) & role.hosts).any?
end
end end
def hosts def hosts
specific_hosts || config.all_hosts (specific_hosts || config.all_hosts).select do |host|
(specific_roles || config.roles).flat_map(&:hosts).include?(host)
end
end
def roles_on(host)
roles.select { |role| role.hosts.include?(host.to_s) }.map(&:name)
end end
def traefik_hosts def traefik_hosts
@@ -37,7 +59,7 @@ class Mrsk::Commander
end end
def accessory_hosts def accessory_hosts
specific_hosts || config.accessories.collect(&:host) specific_hosts || config.accessories.flat_map(&:hosts)
end end
def accessory_names def accessory_names
@@ -45,55 +67,55 @@ class Mrsk::Commander
end end
def app def app(role: nil)
@app ||= Mrsk::Commands::App.new(config) Mrsk::Commands::App.new(config, role: role)
end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end end
def accessory(name) def accessory(name)
Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end end
def auditor def auditor(role: nil)
@auditor ||= Mrsk::Commands::Auditor.new(config) Mrsk::Commands::Auditor.new(config, role: role)
end end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def lock
@lock ||= Mrsk::Commands::Lock.new(config)
end
def with_verbosity(level) def with_verbosity(level)
old_level = SSHKit.config.output_verbosity old_level = self.verbosity
self.verbosity = level
SSHKit.config.output_verbosity = level SSHKit.config.output_verbosity = level
yield yield
ensure ensure
self.verbosity = old_level
SSHKit.config.output_verbosity = old_level SSHKit.config.output_verbosity = old_level
end end
# Test-induced damage!
def reset
@config = @config_file = @destination = @version = nil
@app = @builder = @traefik = @registry = @prune = @auditor = nil
@verbosity = :info
end
private private
def cascading_version
version.presence || ENV["VERSION"] || `git rev-parse HEAD`.strip
end
# Lazy setup of SSHKit # Lazy setup of SSHKit
def configure_sshkit_with(config) def configure_sshkit_with(config)
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options } SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options }

View File

@@ -1,6 +1,7 @@
class Mrsk::Commands::Accessory < Mrsk::Commands::Base class Mrsk::Commands::Accessory < Mrsk::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
:publish_args, :env_args, :volume_args, :label_args, :option_args, to: :accessory_config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@@ -10,13 +11,16 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def run def run
docker :run, docker :run,
"--name", service_name, "--name", service_name,
"-d", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"-p", port, *config.logging_args,
*publish_args,
*env_args, *env_args,
*volume_args, *volume_args,
*label_args, *label_args,
image *option_args,
image,
cmd
end end
def start def start
@@ -34,14 +38,14 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def follow_logs(grep: nil) def follow_logs(grep: nil)
run_over_ssh \ run_over_ssh \
pipe \ pipe \
docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"), docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
end end
@@ -72,7 +76,7 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def run_over_ssh(command) def run_over_ssh(command)
super command, host: host super command, host: hosts.first
end end
@@ -95,11 +99,11 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def remove_container def remove_container
docker :container, :prune, "-f", *service_filter docker :container, :prune, "--force", *service_filter
end end
def remove_image def remove_image
docker :image, :prune, "-a", "-f", *service_filter docker :image, :rm, "--force", image
end end
private private

View File

@@ -1,35 +1,47 @@
class Mrsk::Commands::App < Mrsk::Commands::Base class Mrsk::Commands::App < Mrsk::Commands::Base
def run(role: :web) attr_reader :role
role = config.role(role)
def initialize(config, role: nil)
super(config)
@role = role
end
def run
role = config.role(self.role)
docker :run, docker :run,
"-d", "--detach",
"--restart unless-stopped", "--restart unless-stopped",
"--name", service_with_version, "--name", container_name,
"-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"",
*role.env_args, *role.env_args,
*config.logging_args,
*config.volume_args, *config.volume_args,
*role.label_args, *role.label_args,
*role.option_args,
config.absolute_image, config.absolute_image,
role.cmd role.cmd
end end
def start def start
docker :start, service_with_version docker :start, container_name
end end
def stop def stop(version: nil)
pipe current_container_id, xargs(docker(:stop)) pipe \
version ? container_id_for_version(version) : current_container_id,
xargs(config.stop_wait_time ? docker(:stop, "-t", config.stop_wait_time) : docker(:stop))
end end
def info def info
docker :ps, *service_filter docker :ps, *filter_args
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
current_container_id, current_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" -n #{lines}" if lines} 2>&1", "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
@@ -37,7 +49,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
run_over_ssh \ run_over_ssh \
pipe( pipe(
current_container_id, current_container_id,
"xargs docker logs -t -n 10 -f 2>&1", "xargs docker logs --timestamps --tail 10 --follow 2>&1",
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
), ),
host: host host: host
@@ -47,7 +59,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def execute_in_existing_container(*command, interactive: false) def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
config.service_with_version, container_name,
*command *command
end end
@@ -71,40 +83,41 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def current_container_id def current_container_id
docker :ps, "-q", *service_filter docker :ps, "--quiet", *filter_args
end end
def container_id_for(container_name:) def container_id_for_version(version)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q" container_id_for(container_name: container_name(version))
end end
def current_running_version def current_running_version
# FIXME: Find more graceful way to extract the version from "app-version" than using sed and tail! # FIXME: Find more graceful way to extract the version from "app-version" than using sed and tail!
pipe \ pipe \
docker(:ps, "--filter", "label=service=#{config.service}", "--format", '"{{.Names}}"'), docker(:ps, *filter_args, "--format", '"{{.Names}}"'),
%(sed 's/-/\\n/g'), %(sed 's/-/\\n/g'),
"tail -n 1" "tail -n 1"
end end
def most_recent_version_from_available_images def list_containers
pipe \ docker :container, :ls, "--all", *filter_args
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end end
def list_container_names
def list_containers [ *list_containers, "--format", "'{{ .Names }}'" ]
docker :container, :ls, "-a", *service_filter
end end
def remove_container(version:) def remove_container(version:)
pipe \ pipe \
container_id_for(container_name: service_with_version(version)), container_id_for(container_name: container_name(version)),
xargs(docker(:container, :rm)) xargs(docker(:container, :rm))
end end
def rename_container(version:, new_version:)
docker :rename, container_name(version), container_name(new_version)
end
def remove_containers def remove_containers
docker :container, :prune, "-f", *service_filter docker :container, :prune, "--force", *filter_args
end end
def list_images def list_images
@@ -112,20 +125,23 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def remove_images def remove_images
docker :image, :prune, "-a", "-f", *service_filter docker :image, :prune, "--all", "--force", *filter_args
end end
private private
def service_with_version(version = nil) def container_name(version = nil)
if version [ config.service, role, config.destination, version || config.version ].compact.join("-")
"#{config.service}-#{version}"
else
config.service_with_version
end
end end
def service_filter def filter_args
[ "--filter", "label=service=#{config.service}" ] argumentize "--filter", filters
end
def filters
[ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination
filters << "label=role=#{role}" if role
end
end end
end end

View File

@@ -1,34 +1,57 @@
require "active_support/core_ext/time/conversions" require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base class Mrsk::Commands::Auditor < Mrsk::Commands::Base
attr_reader :role
def initialize(config, role: nil)
super(config)
@role = role
end
# Runs remotely
def record(line) def record(line)
append \ append \
[ :echo, tagged_line(line) ], [ :echo, tagged_record_line(line) ],
audit_log_file audit_log_file
end end
# Runs locally
def broadcast(line)
if broadcast_cmd = config.audit_broadcast_cmd
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
def reveal def reveal
[ :tail, "-n", 50, audit_log_file ] [ :tail, "-n", 50, audit_log_file ]
end end
private private
def audit_log_file def audit_log_file
"mrsk-#{config.service}-audit.log" [ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-")
end end
def tagged_line(line) def tagged_record_line(line)
"'#{tags} #{line}'" tagged_line recorded_at_tag, performer_tag, role_tag, line
end end
def tags def tagged_broadcast_line(line)
"[#{timestamp}] [#{performer}]" tagged_line performer_tag, role_tag, line
end end
def performer def tagged_line(*tags_and_line)
`whoami`.strip "'#{tags_and_line.compact.join(" ")}'"
end end
def timestamp def recorded_at_tag
Time.now.to_fs(:db) "[#{Time.now.to_fs(:db)}]"
end
def performer_tag
"[#{`whoami`.strip}]"
end
def role_tag
"[#{role}]" if role
end end
end end

View File

@@ -1,6 +1,6 @@
module Mrsk::Commands module Mrsk::Commands
class Base class Base
delegate :redact, to: Mrsk::Utils delegate :sensitive, :argumentize, to: Mrsk::Utils
attr_accessor :config attr_accessor :config
@@ -15,6 +15,10 @@ module Mrsk::Commands
end end
end end
def container_id_for(container_name:)
docker :container, :ls, "--all", "--filter", "name=^#{container_name}$", "--quiet"
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands
@@ -35,6 +39,10 @@ module Mrsk::Commands
combine *commands, by: ">>" combine *commands, by: ">>"
end end
def write(*commands)
combine *commands, by: ">"
end
def xargs(command) def xargs(command)
[ :xargs, command ].flatten [ :xargs, command ].flatten
end end

View File

@@ -1,5 +1,5 @@
class Mrsk::Commands::Builder < Mrsk::Commands::Base class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :pull, :info, to: :target delegate :create, :remove, :push, :clean, :pull, :info, to: :target
def name def name
target.class.to_s.remove("Mrsk::Commands::Builder::").underscore target.class.to_s.remove("Mrsk::Commands::Builder::").underscore

View File

@@ -1,19 +1,44 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils delegate :argumentize, to: Mrsk::Utils
def clean
docker :image, :rm, "--force", config.absolute_image
end
def pull def pull
docker :pull, config.absolute_image docker :pull, config.absolute_image
docker :pull, config.latest_image
end end
def build_args def build_options
argumentize "--build-arg", args, redacted: true [ *build_tags, *build_labels, *build_args, *build_secrets, *build_dockerfile ]
end end
def build_secrets def build_context
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } context
end end
private private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
end
def build_labels
argumentize "--label", { service: config.service }
end
def build_args
argumentize "--build-arg", args, sensitive: true
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end
def build_dockerfile
argumentize "--file", dockerfile
end
def args def args
(config.builder && config.builder["args"]) || {} (config.builder && config.builder["args"]) || {}
end end
@@ -21,4 +46,12 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
def secrets def secrets
(config.builder && config.builder["secrets"]) || [] (config.builder && config.builder["secrets"]) || []
end end
def dockerfile
(config.builder && config.builder["dockerfile"]) || "Dockerfile"
end
def context
(config.builder && config.builder["context"]) || "."
end
end end

View File

@@ -12,10 +12,8 @@ class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
"--push", "--push",
"--platform", "linux/amd64,linux/arm64", "--platform", "linux/amd64,linux/arm64",
"--builder", builder_name, "--builder", builder_name,
"-t", config.absolute_image, *build_options,
*build_args, build_context
*build_secrets,
"."
end end
def info def info

View File

@@ -9,8 +9,9 @@ class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def push def push
combine \ combine \
docker(:build, "-t", *build_args, *build_secrets, config.absolute_image, "."), docker(:build, *build_options, build_context),
docker(:push, config.absolute_image) docker(:push, config.absolute_image),
docker(:push, config.latest_image)
end end
def info def info

View File

@@ -16,10 +16,8 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
"--push", "--push",
"--platform", platform, "--platform", platform,
"--builder", builder_name, "--builder", builder_name,
"-t", config.absolute_image, *build_options,
*build_args, build_context
*build_secrets,
"."
end end
def info def info

View File

@@ -0,0 +1,52 @@
class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
EXPOSED_PORT = 3999
def run
web = config.role(:web)
docker :run,
"--detach",
"--name", container_name_with_version,
"--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}",
"--label", "service=#{container_name}",
"-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"",
*web.env_args,
*config.volume_args,
*web.option_args,
config.absolute_image,
web.cmd
end
def curl
[ :curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", health_url ]
end
def logs
pipe container_id, xargs(docker(:logs, "--tail", 50, "2>&1"))
end
def stop
pipe container_id, xargs(docker(:stop))
end
def remove
pipe container_id, xargs(docker(:container, :rm))
end
private
def container_name
[ "healthcheck", config.service, config.destination ].compact.join("-")
end
def container_name_with_version
"#{container_name}-#{config.version}"
end
def container_id
container_id_for(container_name: container_name_with_version)
end
def health_url
"http://localhost:#{EXPOSED_PORT}#{config.healthcheck["path"]}"
end
end

63
lib/mrsk/commands/lock.rb Normal file
View File

@@ -0,0 +1,63 @@
require "active_support/duration"
require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Lock < Mrsk::Commands::Base
def acquire(message, version)
combine \
[:mkdir, lock_dir],
write_lock_details(message, version)
end
def release
combine \
[:rm, lock_details_file],
[:rm, "-r", lock_dir]
end
def status
combine \
stat_lock_dir,
read_lock_details
end
private
def write_lock_details(message, version)
write \
[:echo, "\"#{Base64.encode64(lock_details(message, version))}\""],
lock_details_file
end
def read_lock_details
pipe \
[:cat, lock_details_file],
[:base64, "-d"]
end
def stat_lock_dir
write \
[:stat, lock_dir],
"/dev/null"
end
def lock_dir
:mrsk_lock
end
def lock_details_file
[lock_dir, :details].join("/")
end
def lock_details(message, version)
<<~DETAILS.strip
Locked by: #{locked_by} at #{Time.now.gmtime}
Version: #{version}
Message: #{message}
DETAILS
end
def locked_by
`git config user.name`.strip
rescue Errno::ENOENT
"Unknown"
end
end

View File

@@ -2,15 +2,11 @@ require "active_support/duration"
require "active_support/core_ext/numeric/time" require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Prune < Mrsk::Commands::Base class Mrsk::Commands::Prune < Mrsk::Commands::Base
PRUNE_IMAGES_AFTER = 30.days.in_hours.to_i def images(until_hours: 7.days.in_hours.to_i)
PRUNE_CONTAINERS_AFTER = 3.days.in_hours.to_i docker :image, :prune, "--all", "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h"
def images
docker :image, :prune, "-f", "--filter", "until=#{PRUNE_IMAGES_AFTER}h"
end end
def containers def containers(until_hours: 3.days.in_hours.to_i)
docker :image, :prune, "-f", "--filter", "until=#{PRUNE_IMAGES_AFTER}h" docker :container, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h"
docker :container, :prune, "-f", "--filter", "label=service=#{config.service}", "--filter", "'until=#{PRUNE_CONTAINERS_AFTER}h'"
end end
end end

View File

@@ -2,10 +2,19 @@ class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config delegate :registry, to: :config
def login def login
docker :login, registry["server"], "-u", redact(registry["username"]), "-p", redact(registry["password"]) docker :login, registry["server"], "-u", sensitive(lookup("username")), "-p", sensitive(lookup("password"))
end end
def logout def logout
docker :logout, registry["server"] docker :logout, registry["server"]
end end
private
def lookup(key)
if registry[key].is_a?(Array)
ENV.fetch(registry[key].first).dup
else
registry[key]
end
end
end end

View File

@@ -1,14 +1,22 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
delegate :argumentize, :optionize, to: Mrsk::Utils
DEFAULT_IMAGE = "traefik:v2.9"
CONTAINER_PORT = 80
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
"-d", "--detach",
"--restart unless-stopped", "--restart", "unless-stopped",
"-p 80:80", "--publish", port,
"-v /var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"traefik", *config.logging_args,
*label_args,
*docker_options_args,
image,
"--providers.docker", "--providers.docker",
"--log.level=DEBUG", "--log.level=DEBUG",
*cmd_args *cmd_option_args
end end
def start def start
@@ -20,32 +28,60 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
end end
def info def info
docker :ps, "--filter", "name=traefik" docker :ps, "--filter", "name=^traefik$"
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, "traefik", (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def follow_logs(host:, grep: nil) def follow_logs(host:, grep: nil)
run_over_ssh pipe( run_over_ssh pipe(
docker(:logs, "traefik", "-t", "-n", "10", "-f", "2>&1"), docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
).join(" "), host: host ).join(" "), host: host
end end
def remove_container def remove_container
docker :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik" docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
def remove_image def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def port
"#{host_port}:#{CONTAINER_PORT}"
end end
private private
def cmd_args def label_args
(config.raw_config.dig(:traefik, "args") || { }).collect { |(key, value)| [ "--#{key}", value ] }.flatten argumentize "--label", labels
end
def labels
config.traefik["labels"] || []
end
def image
config.traefik.fetch("image") { DEFAULT_IMAGE }
end
def docker_options_args
optionize(config.traefik["options"] || {})
end
def cmd_option_args
if args = config.traefik["args"]
optionize args, with: "="
else
[]
end
end
def host_port
config.traefik["host_port"] || CONTAINER_PORT
end end
end end

View File

@@ -6,23 +6,24 @@ require "erb"
require "net/ssh/proxy/jump" require "net/ssh/proxy/jump"
class Mrsk::Configuration class Mrsk::Configuration
delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :raw_config, allow_nil: true delegate :service, :image, :servers, :env, :labels, :registry, :builder, :stop_wait_time, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
attr_accessor :version attr_accessor :destination
attr_accessor :raw_config attr_accessor :raw_config
class << self class << self
def create_from(base_config_file, destination: nil, version: "missing") def create_from(config_file:, destination: nil, version: nil)
new(load_config_file(base_config_file).tap do |config| raw_config = load_config_files(config_file, *destination_config_file(config_file, destination))
if destination
config.deep_merge! \ new raw_config, destination: destination, version: version
load_config_file destination_config_file(base_config_file, destination)
end
end, version: version)
end end
private private
def load_config_files(*files)
files.inject({}) { |config, file| config.deep_merge! load_config_file(file) }
end
def load_config_file(file) def load_config_file(file)
if file.exist? if file.exist?
YAML.load(ERB.new(IO.read(file)).result).symbolize_keys YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
@@ -32,18 +33,31 @@ class Mrsk::Configuration
end end
def destination_config_file(base_config_file, destination) def destination_config_file(base_config_file, destination)
dir, basename = base_config_file.split base_config_file.sub_ext(".#{destination}.yml") if destination
dir.join basename.to_s.remove(".yml") + ".#{destination}.yml"
end end
end end
def initialize(raw_config, version: "missing", validate: true) def initialize(raw_config, destination: nil, version: nil, validate: true)
@raw_config = ActiveSupport::InheritableOptions.new(raw_config) @raw_config = ActiveSupport::InheritableOptions.new(raw_config)
@version = version @destination = destination
@declared_version = version
valid? if validate valid? if validate
end end
def version=(version)
@declared_version = version
end
def version
@declared_version.presence || ENV["VERSION"] || current_commit_hash
end
def abbreviated_version
Mrsk::Utils.abbreviate_version(version)
end
def roles def roles
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) } @roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
end end
@@ -62,15 +76,15 @@ class Mrsk::Configuration
def all_hosts def all_hosts
roles.flat_map(&:hosts) roles.flat_map(&:hosts).uniq
end end
def primary_web_host def primary_web_host
role(:web).hosts.first role(:web).primary_host
end end
def traefik_hosts def traefik_hosts
roles.select(&:running_traefik?).flat_map(&:hosts) roles.select(&:running_traefik?).flat_map(&:hosts).uniq
end end
@@ -82,6 +96,10 @@ class Mrsk::Configuration
"#{repository}:#{version}" "#{repository}:#{version}"
end end
def latest_image
"#{repository}:latest"
end
def service_with_version def service_with_version
"#{service}-#{version}" "#{service}-#{version}"
end end
@@ -103,6 +121,16 @@ class Mrsk::Configuration
end end
end end
def logging_args
if raw_config.logging.present?
optionize({ "log-driver" => raw_config.logging["driver"] }.compact) +
argumentize("--log-opt", raw_config.logging["options"])
else
argumentize("--log-opt", { "max-size" => "10m" })
end
end
def ssh_user def ssh_user
if raw_config.ssh.present? if raw_config.ssh.present?
raw_config.ssh["user"] || "root" raw_config.ssh["user"] || "root"
@@ -115,6 +143,8 @@ class Mrsk::Configuration
if raw_config.ssh.present? && raw_config.ssh["proxy"] if raw_config.ssh.present? && raw_config.ssh["proxy"]
Net::SSH::Proxy::Jump.new \ Net::SSH::Proxy::Jump.new \
raw_config.ssh["proxy"].include?("@") ? raw_config.ssh["proxy"] : "root@#{raw_config.ssh["proxy"]}" raw_config.ssh["proxy"].include?("@") ? raw_config.ssh["proxy"] : "root@#{raw_config.ssh["proxy"]}"
elsif raw_config.ssh.present? && raw_config.ssh["proxy_command"]
Net::SSH::Proxy::Command.new(raw_config.ssh["proxy_command"])
end end
end end
@@ -123,6 +153,18 @@ class Mrsk::Configuration
end end
def audit_broadcast_cmd
raw_config.audit_broadcast_cmd
end
def healthcheck
{ "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
end
def readiness_delay
raw_config.readiness_delay || 7
end
def valid? def valid?
ensure_required_keys_present && ensure_env_available ensure_required_keys_present && ensure_env_available
end end
@@ -141,10 +183,15 @@ class Mrsk::Configuration
volume_args: volume_args, volume_args: volume_args,
ssh_options: ssh_options, ssh_options: ssh_options,
builder: raw_config.builder, builder: raw_config.builder,
accessories: raw_config.accessories accessories: raw_config.accessories,
logging: logging_args,
healthcheck: healthcheck
}.compact }.compact
end end
def traefik
raw_config.traefik || {}
end
private private
# Will raise ArgumentError if any required config keys are missing # Will raise ArgumentError if any required config keys are missing
@@ -161,6 +208,12 @@ class Mrsk::Configuration
raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)" raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)"
end end
roles.each do |role|
if role.hosts.empty?
raise ArgumentError, "No servers specified for the #{role.name} role"
end
end
true true
end end
@@ -175,4 +228,13 @@ class Mrsk::Configuration
def role_names def role_names
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end end
def current_commit_hash
@current_commit_hash ||=
if system("git rev-parse")
`git rev-parse HEAD`.strip
else
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end
end
end end

View File

@@ -1,5 +1,5 @@
class Mrsk::Configuration::Accessory class Mrsk::Configuration::Accessory
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
attr_accessor :name, :specifics attr_accessor :name, :specifics
@@ -15,18 +15,24 @@ class Mrsk::Configuration::Accessory
specifics["image"] specifics["image"]
end end
def host def hosts
specifics["host"] || raise(ArgumentError, "Missing host for accessory") if (specifics.keys & ["host", "hosts", "roles"]).size != 1
raise ArgumentError, "Specify one of `host`, `hosts` or `roles` for accessory `#{name}`"
end
hosts_from_host || hosts_from_hosts || hosts_from_roles
end end
def port def port
if specifics["port"].to_s.include?(":") if port = specifics["port"]&.to_s
specifics["port"] port.include?(":") ? port : "#{port}:#{port}"
else
"#{specifics["port"]}:#{specifics["port"]}"
end end
end end
def publish_args
argumentize "--publish", port if port
end
def labels def labels
default_labels.merge(specifics["labels"] || {}) default_labels.merge(specifics["labels"] || {})
end end
@@ -65,6 +71,18 @@ class Mrsk::Configuration::Accessory
argumentize "--volume", volumes argumentize "--volume", volumes
end end
def option_args
if args = specifics["options"]
optionize args
else
[]
end
end
def cmd
specifics["cmd"]
end
private private
attr_accessor :config attr_accessor :config
@@ -120,4 +138,32 @@ class Mrsk::Configuration::Accessory
def service_data_directory def service_data_directory
"$PWD/#{service_name}" "$PWD/#{service_name}"
end end
def hosts_from_host
if specifics.key?("host")
host = specifics["host"]
if host
[host]
else
raise ArgumentError, "Missing host for accessory `#{name}`"
end
end
end
def hosts_from_hosts
if specifics.key?("hosts")
hosts = specifics["hosts"]
if hosts.is_a?(Array)
hosts
else
raise ArgumentError, "Hosts should be an Array for accessory `#{name}`"
end
end
end
def hosts_from_roles
if specifics.key?("roles")
specifics["roles"].flat_map { |role| config.role(role).hosts }
end
end
end end

View File

@@ -1,5 +1,5 @@
class Mrsk::Configuration::Role class Mrsk::Configuration::Role
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
attr_accessor :name attr_accessor :name
@@ -7,6 +7,10 @@ class Mrsk::Configuration::Role
@name, @config = name.inquiry, config @name, @config = name.inquiry, config
end end
def primary_host
hosts.first
end
def hosts def hosts
@hosts ||= extract_hosts_from_config @hosts ||= extract_hosts_from_config
end end
@@ -35,6 +39,14 @@ class Mrsk::Configuration::Role
specializations["cmd"] specializations["cmd"]
end end
def option_args
if args = specializations["options"]
optionize args
else
[]
end
end
def running_traefik? def running_traefik?
name.web? || specializations["traefik"] name.web? || specializations["traefik"]
end end
@@ -47,28 +59,37 @@ class Mrsk::Configuration::Role
config.servers config.servers
else else
servers = config.servers[name] servers = config.servers[name]
servers.is_a?(Array) ? servers : servers["hosts"] servers.is_a?(Array) ? servers : Array(servers["hosts"])
end end
end end
def default_labels def default_labels
{ "service" => config.service, "role" => name } if config.destination
{ "service" => config.service, "role" => name, "destination" => config.destination }
else
{ "service" => config.service, "role" => name }
end
end end
def traefik_labels def traefik_labels
if running_traefik? if running_traefik?
{ {
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'", "traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up", "traefik.http.services.#{traefik_service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", "traefik.http.services.#{traefik_service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3", "traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" "traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms",
"traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker"
} }
else else
{} {}
end end
end end
def traefik_service
[ config.service, name, config.destination ].compact.join("-")
end
def custom_labels def custom_labels
Hash.new.tap do |labels| Hash.new.tap do |labels|
labels.merge!(config.labels) if config.labels.present? labels.merge!(config.labels) if config.labels.present?

View File

@@ -1,13 +1,15 @@
module Mrsk::Utils module Mrsk::Utils
extend self extend self
# Return a list of shell arguments using the same named argument against the passed attributes (hash or array). # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
def argumentize(argument, attributes, redacted: false) def argumentize(argument, attributes, sensitive: false)
Array(attributes).flat_map do |k, v| Array(attributes).flat_map do |key, value|
if v.present? if value.present?
[ argument, redacted ? redact("#{k}=#{v}") : "#{k}=#{v}" ] attr = "#{key}=#{escape_shell_value(value)}"
attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive
[ argument, attr]
else else
[ argument, k ] [ argument, key ]
end end
end end
end end
@@ -16,14 +18,68 @@ module Mrsk::Utils
# but redacts and expands secrets. # but redacts and expands secrets.
def argumentize_env_with_secrets(env) def argumentize_env_with_secrets(env)
if (secrets = env["secret"]).present? if (secrets = env["secret"]).present?
argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, redacted: true) + argumentize("-e", env["clear"]) argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, sensitive: true) + argumentize("-e", env["clear"])
else else
argumentize "-e", env.fetch("clear", env) argumentize "-e", env.fetch("clear", env)
end end
end end
# Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
def redact(arg) # Used in execute_command to hide redact() args a user passes in def optionize(args, with: nil)
arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc options = if with
flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape_shell_value(value)}" }
else
flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }
end
options.flatten.compact
end
# Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
def flatten_args(args)
args.flat_map { |key, value| value.try(:map) { |entry| [key, entry] } || [ [ key, value ] ] }
end
# Marks sensitive values for redaction in logs and human-visible output.
# Pass `redaction:` to change the default `"[REDACTED]"` redaction, e.g.
# `sensitive "#{arg}=#{secret}", redaction: "#{arg}=xxxx"
def sensitive(...)
Mrsk::Utils::Sensitive.new(...)
end
def redacted(value)
case
when value.respond_to?(:redaction)
value.redaction
when value.respond_to?(:transform_values)
value.transform_values { |value| redacted value }
when value.respond_to?(:map)
value.map { |element| redacted element }
else
value
end
end
def unredacted(value)
case
when value.respond_to?(:unredacted)
value.unredacted
when value.respond_to?(:transform_values)
value.transform_values { |value| unredacted value }
when value.respond_to?(:map)
value.map { |element| unredacted element }
else
value
end
end
# Escape a value to make it safe for shell use.
def escape_shell_value(value)
value.to_s.dump.gsub(/`/, '\\\\`')
end
# Abbreviate a git revhash for concise display
def abbreviate_version(version)
version[0...7] if version
end end
end end

View File

@@ -0,0 +1,19 @@
require "active_support/core_ext/module/delegation"
class Mrsk::Utils::Sensitive
# So SSHKit knows to redact these values.
include SSHKit::Redaction
attr_reader :unredacted, :redaction
delegate :to_s, to: :unredacted
delegate :inspect, to: :redaction
def initialize(value, redaction: "[REDACTED]")
@unredacted, @redaction = value, redaction
end
# Sensitive values won't leak into YAML output.
def encode_with(coder)
coder.represent_scalar nil, redaction
end
end

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.6.1" VERSION = "0.11.0"
end end

View File

@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
spec.authors = [ "David Heinemeier Hansson" ] spec.authors = [ "David Heinemeier Hansson" ]
spec.email = "dhh@hey.com" spec.email = "dhh@hey.com"
spec.homepage = "https://github.com/rails/mrsk" spec.homepage = "https://github.com/rails/mrsk"
spec.summary = "Deploy Rails apps in containers to servers running Docker with zero downtime." spec.summary = "Deploy web apps in containers to servers running Docker with zero downtime."
spec.license = "MIT" spec.license = "MIT"
spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"] spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"]
@@ -14,7 +14,14 @@ Gem::Specification.new do |spec|
spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", "~> 1.21" spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "net-ssh", "~> 7.0"
spec.add_dependency "thor", "~> 1.2" spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "dotenv", "~> 2.8"
spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "zeitwerk", "~> 2.5"
spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0"
spec.add_development_dependency "debug"
spec.add_development_dependency "mocha"
spec.add_development_dependency "railties"
end end

View File

@@ -1,36 +1,140 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliAccessoryTest < CliTestCase class CliAccessoryTest < CliTestCase
test "boot" do
Mrsk::Cli::Accessory.any_instance.expects(:directories).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:upload).with("mysql")
run_command("boot", "mysql").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
end
end
test "boot all" do
Mrsk::Cli::Accessory.any_instance.expects(:directories).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:upload).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:directories).with("redis")
Mrsk::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "all").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output
assert_match /docker login.*on 1.1.1.1/, output
assert_match /docker login.*on 1.1.1.2/, output
assert_match "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
assert_match "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
end
end
test "upload" do test "upload" do
assert_match "test/fixtures/files/my.cnf app-mysql/etc/mysql/my.cnf", run_command("upload", "mysql") run_command("upload", "mysql").tap do |output|
assert_match "mkdir -p app-mysql/etc/mysql", output
assert_match "test/fixtures/files/my.cnf app-mysql/etc/mysql/my.cnf", output
assert_match "chmod 755 app-mysql/etc/mysql/my.cnf", output
end
end end
test "directories" do test "directories" do
assert_match "mkdir -p $PWD/app-mysql/data", run_command("directories", "mysql") assert_match "mkdir -p $PWD/app-mysql/data", run_command("directories", "mysql")
end end
test "remove service direcotry" do test "reboot" do
assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql") Mrsk::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:boot).with("mysql")
run_command("reboot", "mysql")
end end
test "boot" do test "start" do
assert_match "Running docker run --name app-mysql -d --restart unless-stopped -p 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=% --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=app-mysql mysql:5.7 on 1.1.1.3", run_command("boot", "mysql") assert_match "docker container start app-mysql", run_command("start", "mysql")
end
test "stop" do
assert_match "docker container stop app-mysql", run_command("stop", "mysql")
end
test "restart" do
Mrsk::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:start).with("mysql")
run_command("restart", "mysql")
end
test "details" do
assert_match "docker ps --filter label=service=app-mysql", run_command("details", "mysql")
end
test "details with all" do
run_command("details", "all").tap do |output|
assert_match "docker ps --filter label=service=app-mysql", output
assert_match "docker ps --filter label=service=app-redis", output
end
end end
test "exec" do test "exec" do
run_command("exec", "mysql", "mysql -v").tap do |output| run_command("exec", "mysql", "mysql -v").tap do |output|
assert_match /Launching command from new container/, output assert_match "Launching command from new container", output
assert_match /mysql -v/, output assert_match "mysql -v", output
end end
end end
test "exec with reuse" do test "exec with reuse" do
run_command("exec", "mysql", "--reuse", "mysql -v").tap do |output| run_command("exec", "mysql", "--reuse", "mysql -v").tap do |output|
assert_match /Launching command from existing container/, output assert_match "Launching command from existing container", output
assert_match %r[docker exec app-mysql mysql -v], output assert_match "docker exec app-mysql mysql -v", output
end end
end end
test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 2>&1'")
assert_match "docker logs app-mysql --tail 100 --timestamps 2>&1", run_command("logs", "mysql")
end
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1", run_command("logs", "mysql", "--follow")
end
test "remove with confirmation" do
Mrsk::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_image).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_service_directory).with("mysql")
run_command("remove", "mysql", "-y")
end
test "remove all with confirmation" do
Mrsk::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_image).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:remove_service_directory).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:stop).with("redis")
Mrsk::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Mrsk::Cli::Accessory.any_instance.expects(:remove_image).with("redis")
Mrsk::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis")
run_command("remove", "all", "-y")
end
test "remove_container" do
assert_match "docker container prune --force --filter label=service=app-mysql", run_command("remove_container", "mysql")
end
test "remove_image" do
assert_match "docker image rm --force mysql", run_command("remove_image", "mysql")
end
test "remove_service_directory" do
assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql")
end
private private
def run_command(*command) def run_command(*command)
stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }

View File

@@ -2,22 +2,31 @@ require_relative "cli_test_case"
class CliAppTest < CliTestCase class CliAppTest < CliTestCase
test "boot" do test "boot" do
assert_match /Running docker run -d --restart unless-stopped/, run_command("boot") # Stub current version fetch
end SSHKit::Backend::Abstract.any_instance.stubs(:capture).returns("123") # old version
test "boot will reboot if same version is already running" do
run_command("details") # Preheat MRSK const
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
MRSK.app.stubs(:run).raises(SSHKit::Command::Failed.new("already in use")).then.returns([ :docker, :run ])
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match /Rebooting container with same version already deployed/, output # Can't start what's already running assert_match "docker run --detach --restart unless-stopped", output
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output # Stop what's running assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
assert_match /docker container ls -a -f name=app-999 -q \| xargs docker container rm/, output # Remove old container end
assert_match /docker run/, output # Start new container end
test "boot will rename if same version is already running" do
run_command("details") # Preheat MRSK const
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet")
.returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1")
.returns("123") # old version
run_command("boot").tap do |output|
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker rename .* .*/, output
assert_match "docker run --detach --restart unless-stopped", output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
ensure ensure
Thread.report_on_exception = true Thread.report_on_exception = true
@@ -25,43 +34,102 @@ class CliAppTest < CliTestCase
test "start" do test "start" do
run_command("start").tap do |output| run_command("start").tap do |output|
assert_match /docker start app-999/, output assert_match "docker start app-web-999", output
end end
end end
test "stop" do test "stop" do
run_command("stop").tap do |output| run_command("stop").tap do |output|
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output assert_match "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop", output
end end
end end
test "details" do test "details" do
run_command("details").tap do |output| run_command("details").tap do |output|
assert_match /docker ps --filter label=service=app/, output assert_match "docker ps --filter label=service=app --filter label=role=web", output
end
end
test "remove" do
run_command("remove").tap do |output|
assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop")}/, output
assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output
end end
end end
test "remove_container" do test "remove_container" do
run_command("remove_container", "1234567").tap do |output| run_command("remove_container", "1234567").tap do |output|
assert_match /docker container ls -a -f name=app-1234567 -q \| xargs docker container rm/, output assert_match "docker container ls --all --filter name=^app-web-1234567$ --quiet | xargs docker container rm", output
end
end
test "remove_containers" do
run_command("remove_containers").tap do |output|
assert_match "docker container prune --force --filter label=service=app", output
end
end
test "remove_images" do
run_command("remove_images").tap do |output|
assert_match "docker image prune --all --force --filter label=service=app", output
end end
end end
test "exec" do test "exec" do
run_command("exec", "ruby -v").tap do |output| run_command("exec", "ruby -v").tap do |output|
assert_match /ruby -v/, output assert_match "docker run --rm dhh/app:latest ruby -v", output
end end
end end
test "exec with reuse" do test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output| run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match %r[docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1], output # Get current version assert_match "docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", output # Get current version
assert_match %r[docker exec app-999 ruby -v], output assert_match "docker exec app-web-999 ruby -v", output
end
end
test "containers" do
run_command("containers").tap do |output|
assert_match "docker container ls --all --filter label=service=app", output
end
end
test "images" do
run_command("images").tap do |output|
assert_match "docker image ls dhh/app", output
end
end
test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 2>&1'")
assert_match "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --tail 100 2>&1", run_command("logs")
end
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 --follow 2>&1'")
assert_match "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow")
end
test "version" do
run_command("version").tap do |output|
assert_match "docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", output
end
end
test "version through main" do
stdouted { Mrsk::Cli::Main.start(["app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1"]) }.tap do |output|
assert_match "docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", output
end end
end end
private private
def run_command(*command) def run_command(*command)
stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1"]) }
end end
end end

82
test/cli/build_test.rb Normal file
View File

@@ -0,0 +1,82 @@
require_relative "cli_test_case"
class CliBuildTest < CliTestCase
test "deliver" do
Mrsk::Cli::Build.any_instance.expects(:push)
Mrsk::Cli::Build.any_instance.expects(:pull)
run_command("deliver")
end
test "push" do
run_command("push").tap do |output|
assert_match /docker buildx build --push --platform linux\/amd64,linux\/arm64 --builder mrsk-app-multiarch -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end
end
test "push without builder" do
stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg| arg == :docker }
.raises(SSHKit::Command::Failed.new("no builder"))
.then
.returns(true)
run_command("push").tap do |output|
assert_match /Missing compatible builder, so creating a new one first/, output
end
end
test "pull" do
run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull dhh\/app:latest/, output
end
end
test "create" do
run_command("create").tap do |output|
assert_match /docker buildx create --use --name mrsk-app-multiarch/, output
end
end
test "create with error" do
stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg| arg == :docker }
.raises(SSHKit::Command::Failed.new("stderr=error"))
run_command("create").tap do |output|
assert_match /Couldn't create remote builder: error/, output
end
end
test "remove" do
run_command("remove").tap do |output|
assert_match /docker buildx rm mrsk-app-multiarch/, output
end
end
test "details" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with(:docker, :context, :ls, "&&", :docker, :buildx, :ls)
.returns("docker builder info")
run_command("details").tap do |output|
assert_match /Builder: multiarch/, output
assert_match /docker builder info/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
def stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :mkdir && arg2 == :mrsk_lock }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == "mrsk_lock/details" }
end
end

View File

@@ -8,17 +8,22 @@ class CliTestCase < ActiveSupport::TestCase
ENV["VERSION"] = "999" ENV["VERSION"] = "999"
ENV["RAILS_MASTER_KEY"] = "123" ENV["RAILS_MASTER_KEY"] = "123"
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
Object.send(:remove_const, :MRSK)
Object.const_set(:MRSK, Mrsk::Commander.new)
end end
teardown do teardown do
ENV.delete("RAILS_MASTER_KEY") ENV.delete("RAILS_MASTER_KEY")
ENV.delete("MYSQL_ROOT_PASSWORD") ENV.delete("MYSQL_ROOT_PASSWORD")
ENV.delete("VERSION") ENV.delete("VERSION")
MRSK.reset
end end
private private
def stdouted def stdouted
capture(:stdout) { yield }.strip capture(:stdout) { yield }.strip
end end
end
def stderred
capture(:stderr) { yield }.strip
end
end

View File

@@ -0,0 +1,70 @@
require_relative "cli_test_case"
class CliHealthcheckTest < CliTestCase
test "perform" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:sleep) # No sleeping when retrying
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "MRSK_CONTAINER_NAME=\"healthcheck-app\"", "dhh/app:999")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :container, :rm, raise_on_non_zero_exit: false)
# Fail twice to test retry logic
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", "http://localhost:3999/up")
.raises(SSHKit::Command::Failed)
.then
.raises(SSHKit::Command::Failed)
.then
.returns("200")
run_command("perform").tap do |output|
assert_match "Health check against /up failed to respond, retrying in 1s (attempt 1/7)...", output
assert_match "Health check against /up failed to respond, retrying in 2s (attempt 2/7)...", output
assert_match "Health check against /up succeeded with 200 OK!", output
end
end
test "perform failing because of curl" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute) # No need to execute anything here
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", "http://localhost:3999/up")
.returns("curl: command not found")
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1")
exception = assert_raises SSHKit::Runner::ExecuteError do
run_command("perform")
end
assert_match "Health check against /up failed to return 200 OK!", exception.message
end
test "perform failing for unknown reason" do
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute) # No need to execute anything here
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", "http://localhost:3999/up")
.returns("500")
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1")
exception = assert_raises do
run_command("perform")
end
assert_match "Health check against /up failed with status 500", exception.message
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Healthcheck.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

20
test/cli/lock_test.rb Normal file
View File

@@ -0,0 +1,20 @@
require_relative "cli_test_case"
class CliLockTest < CliTestCase
test "status" do
run_command("status") do |output|
assert_match "stat lock", output
end
end
test "release" do
run_command("release") do |output|
assert_match "rm -rf lock", output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Lock.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -1,8 +1,301 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliMainTest < CliTestCase class CliMainTest < CliTestCase
test "setup" do
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap")
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:accessory:boot", [ "all" ])
Mrsk::Cli::Main.any_instance.expects(:deploy)
run_command("setup")
end
test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options)
run_command("deploy").tap do |output|
assert_match /Ensure curl and Docker are installed/, output
assert_match /Log into image registry/, output
assert_match /Build and push app image/, output
assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Prune old containers and images/, output
end
end
test "deploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options)
run_command("deploy", "--skip_push").tap do |output|
assert_match /Acquiring the deploy lock/, output
assert_match /Ensure curl and Docker are installed/, output
assert_match /Log into image registry/, output
assert_match /Pull app image/, output
assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output
end
end
test "deploy when locked" do
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [:mkdir, :mrsk_lock] }
.raises(RuntimeError, "mkdir: cannot create directory mrsk_lock: File exists")
Mrsk::Cli::Base.any_instance.expects(:invoke).with("mrsk:cli:lock:status", [])
assert_raises(Mrsk::Cli::LockError) do
run_command("deploy")
end
end
test "deploy error when locking" do
Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [:mkdir, :mrsk_lock] }
.raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known")
assert_raises(SSHKit::Runner::ExecuteError) do
run_command("deploy")
end
end
test "deploy errors leave lock in place" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke)
.with("mrsk:cli:server:bootstrap", [], invoke_options)
.raises(RuntimeError)
assert_equal 0, MRSK.lock_count
assert_raises(RuntimeError) do
stderred { run_command("deploy") }
end
assert_equal 1, MRSK.lock_count
end
test "redeploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options)
run_command("redeploy").tap do |output|
assert_match /Build and push app image/, output
assert_match /Ensure app can pass healthcheck/, output
end
end
test "redeploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options)
run_command("redeploy", "--skip_push").tap do |output|
assert_match /Pull app image/, output
assert_match /Ensure app can pass healthcheck/, output
end
end
test "rollback bad version" do
# Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(false)
run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output|
assert_match /docker container ls --all --filter name=\^app-web-nonsense\$ --quiet/, output
assert_match /The app version 'nonsense' is not available as a container/, output
end
end
test "rollback good version" do
Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=workers", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").at_least_once
run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output|
assert_match "Start version 123", output
assert_match "docker start app-web-123", output
assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
end
end
test "rollback without old version" do
Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("").at_least_once
run_command("rollback", "123").tap do |output|
assert_match "Start version 123", output
assert_match "docker start app-web-123", output
assert_no_match "docker stop", output
end
end
test "details" do
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:details")
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:details")
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:accessory:details", [ "all" ])
run_command("details")
end
test "audit" do
run_command("audit").tap do |output|
assert_match /tail -n 50 mrsk-app-audit.log on 1.1.1.1/, output
assert_match /App Host: 1.1.1.1/, output
end
end
test "config" do
run_command("config", config_file: "deploy_simple").tap do |output|
config = YAML.load(output)
assert_equal ["web"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "dhh/app", config[:repository]
assert_equal "dhh/app:999", config[:absolute_image]
assert_equal "app-999", config[:service_with_version]
end
end
test "config with roles" do
run_command("config", config_file: "deploy_with_roles").tap do |output|
config = YAML.load(output)
assert_equal ["web", "workers"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "registry.digitalocean.com/dhh/app", config[:repository]
assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image]
assert_equal "app-999", config[:service_with_version]
end
end
test "config with destination" do
run_command("config", "-d", "world", config_file: "deploy_for_dest").tap do |output|
config = YAML.load(output)
assert_equal ["web"], config[:roles]
assert_equal ["1.1.1.1", "1.1.1.2"], config[:hosts]
assert_equal "999", config[:version]
assert_equal "registry.digitalocean.com/dhh/app", config[:repository]
assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image]
assert_equal "app-999", config[:service_with_version]
end
end
test "init" do
Pathname.any_instance.expects(:exist?).returns(false).twice
FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r)
run_command("init").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output
assert_match /Created \.env file/, output
end
end
test "init with existing config" do
Pathname.any_instance.expects(:exist?).returns(true).twice
run_command("init").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
end
end
test "init with bundle option" do
Pathname.any_instance.expects(:exist?).returns(false).times(3)
FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r)
run_command("init", "--bundle").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output
assert_match /Created \.env file/, output
assert_match /Adding MRSK to Gemfile and bundle/, output
assert_match /bundle add mrsk/, output
assert_match /bundle binstubs mrsk/, output
assert_match /Created binstub file in bin\/mrsk/, output
end
end
test "init with bundle option and existing binstub" do
Pathname.any_instance.expects(:exist?).returns(true).times(3)
FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r)
run_command("init", "--bundle").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
assert_match /Binstub already exists in bin\/mrsk \(remove first to create a new one\)/, output
end
end
test "envify" do
File.expects(:read).with(".env.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env", "HELLO=world", perm: 0600)
run_command("envify")
end
test "envify with destination" do
File.expects(:read).with(".env.staging.erb").returns("HELLO=<%= 'world' %>")
File.expects(:write).with(".env.staging", "HELLO=world", perm: 0600)
run_command("envify", "-d", "staging")
end
test "remove with confirmation" do
run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, output
assert_match /docker container prune --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output
assert_match /docker image prune --all --force --filter label=service=app/, output
assert_match /docker container stop app-mysql/, output
assert_match /docker container prune --force --filter label=service=app-mysql/, output
assert_match /docker image rm --force mysql/, output
assert_match /rm -rf app-mysql/, output
assert_match /docker container stop app-redis/, output
assert_match /docker container prune --force --filter label=service=app-redis/, output
assert_match /docker image rm --force redis/, output
assert_match /rm -rf app-redis/, output
assert_match /docker logout/, output
end
end
test "version" do test "version" do
version = stdouted { Mrsk::Cli::Main.new.version } version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version assert_equal Mrsk::VERSION, version
end end
private
def run_command(*command, config_file: "deploy_simple")
stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/#{config_file}.yml"]) }
end
end end

27
test/cli/prune_test.rb Normal file
View File

@@ -0,0 +1,27 @@
require_relative "cli_test_case"
class CliPruneTest < CliTestCase
test "all" do
Mrsk::Cli::Prune.any_instance.expects(:containers)
Mrsk::Cli::Prune.any_instance.expects(:images)
run_command("all")
end
test "images" do
run_command("images").tap do |output|
assert_match /docker image prune --all --force --filter label=service=app --filter until=168h on 1.1.1.\d/, output
end
end
test "containers" do
run_command("containers").tap do |output|
assert_match /docker container prune --force --filter label=service=app --filter until=72h on 1.1.1.\d/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Prune.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

21
test/cli/registry_test.rb Normal file
View File

@@ -0,0 +1,21 @@
require_relative "cli_test_case"
class CliRegistryTest < CliTestCase
test "login" do
run_command("login").tap do |output|
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
end
end
test "logout" do
run_command("logout").tap do |output|
assert_match /docker logout on 1.1.1.\d/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Registry.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

16
test/cli/server_test.rb Normal file
View File

@@ -0,0 +1,16 @@
require_relative "cli_test_case"
class CliServerTest < CliTestCase
test "bootstrap" do
run_command("bootstrap").tap do |output|
assert_match /which curl/, output
assert_match /which docker/, output
assert_match /apt-get update -y && apt-get install curl docker.io -y/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Server.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

86
test/cli/traefik_test.rb Normal file
View File

@@ -0,0 +1,86 @@
require_relative "cli_test_case"
class CliTraefikTest < CliTestCase
test "boot" do
run_command("boot").tap do |output|
assert_match "docker login", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=DEBUG", output
end
end
test "reboot" do
Mrsk::Cli::Traefik.any_instance.expects(:stop)
Mrsk::Cli::Traefik.any_instance.expects(:remove_container)
Mrsk::Cli::Traefik.any_instance.expects(:boot)
run_command("reboot")
end
test "start" do
run_command("start").tap do |output|
assert_match "docker container start traefik", output
end
end
test "stop" do
run_command("stop").tap do |output|
assert_match "docker container stop traefik", output
end
end
test "restart" do
Mrsk::Cli::Traefik.any_instance.expects(:stop)
Mrsk::Cli::Traefik.any_instance.expects(:start)
run_command("restart")
end
test "details" do
run_command("details").tap do |output|
assert_match "docker ps --filter name=^traefik$", output
end
end
test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with(:docker, :logs, "traefik", " --tail 100", "--timestamps", "2>&1")
.returns("Log entry")
run_command("logs").tap do |output|
assert_match "Traefik Host: 1.1.1.1", output
assert_match "Log entry", output
end
end
test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'")
assert_match "docker logs traefik --timestamps --tail 10 --follow", run_command("logs", "--follow")
end
test "remove" do
Mrsk::Cli::Traefik.any_instance.expects(:stop)
Mrsk::Cli::Traefik.any_instance.expects(:remove_container)
Mrsk::Cli::Traefik.any_instance.expects(:remove_image)
run_command("remove")
end
test "remove_container" do
run_command("remove_container").tap do |output|
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output
end
end
test "remove_image" do
run_command("remove_image").tap do |output|
assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik", output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Traefik.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -2,7 +2,9 @@ require "test_helper"
class CommanderTest < ActiveSupport::TestCase class CommanderTest < ActiveSupport::TestCase
setup do setup do
@mrsk = Mrsk::Commander.new config_file: Pathname.new(File.expand_path("fixtures/deploy_with_roles.yml", __dir__)) @mrsk = Mrsk::Commander.new.tap do |mrsk|
mrsk.configure config_file: Pathname.new(File.expand_path("fixtures/deploy_with_roles.yml", __dir__))
end
end end
test "lazy configuration" do test "lazy configuration" do
@@ -12,18 +14,29 @@ class CommanderTest < ActiveSupport::TestCase
test "overwriting hosts" do test "overwriting hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_hosts = [ "1.2.3.4", "1.2.3.5" ] @mrsk.specific_hosts = [ "1.1.1.1", "1.1.1.2" ]
assert_equal [ "1.2.3.4", "1.2.3.5" ], @mrsk.hosts assert_equal [ "1.1.1.1", "1.1.1.2" ], @mrsk.hosts
end end
test "overwriting hosts with roles" do test "filtering hosts by filtering roles" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_roles = [ "workers", "web" ] @mrsk.specific_roles = [ "web" ]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts assert_equal [ "1.1.1.1", "1.1.1.2" ], @mrsk.hosts
end
test "filtering roles" do
assert_equal [ "web", "workers" ], @mrsk.roles.map(&:name)
@mrsk.specific_roles = [ "workers" ] @mrsk.specific_roles = [ "workers" ]
assert_equal [ "1.1.1.3", "1.1.1.4" ], @mrsk.hosts assert_equal [ "workers" ], @mrsk.roles.map(&:name)
end
test "filtering roles by filtering hosts" do
assert_equal [ "web", "workers" ], @mrsk.roles.map(&:name)
@mrsk.specific_hosts = [ "1.1.1.3" ]
assert_equal [ "workers" ], @mrsk.roles.map(&:name)
end end
test "overwriting hosts with primary" do test "overwriting hosts with primary" do
@@ -32,4 +45,14 @@ class CommanderTest < ActiveSupport::TestCase
@mrsk.specific_primary! @mrsk.specific_primary!
assert_equal [ "1.1.1.1" ], @mrsk.hosts assert_equal [ "1.1.1.1" ], @mrsk.hosts
end end
test "primary_host with specific hosts via role" do
@mrsk.specific_roles = "web"
assert_equal "1.1.1.1", @mrsk.primary_host
end
test "roles_on" do
assert_equal [ "web" ], @mrsk.roles_on("1.1.1.1")
assert_equal [ "workers" ], @mrsk.roles_on("1.1.1.3")
end
end end

View File

@@ -3,11 +3,11 @@ require "test_helper"
class CommandsAccessoryTest < ActiveSupport::TestCase class CommandsAccessoryTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ], servers: [ "1.1.1.1" ],
accessories: { accessories: {
"mysql" => { "mysql" => {
"image" => "mysql:8.0", "image" => "private.registry/mysql:8.0",
"host" => "1.1.1.5", "host" => "1.1.1.5",
"port" => "3306", "port" => "3306",
"env" => { "env" => {
@@ -32,14 +32,14 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
"volumes" => [ "volumes" => [
"/var/lib/redis:/data" "/var/lib/redis:/data"
] ]
},
"busybox" => {
"image" => "busybox:latest",
"host" => "1.1.1.7"
} }
} }
} }
@config = Mrsk::Configuration.new(@config)
@mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql)
@redis = Mrsk::Commands::Accessory.new(@config, name: :redis)
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end end
@@ -49,67 +49,103 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
[:docker, :run, "--name", "app-mysql", "-d", "--restart", "unless-stopped", "-p", "3306:3306", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "--label", "service=app-mysql", "mysql:8.0"], @mysql.run "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 3306:3306 -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" private.registry/mysql:8.0",
new_command(:mysql).run.join(" ")
assert_equal \ assert_equal \
[:docker, :run, "--name", "app-redis", "-d", "--restart", "unless-stopped", "-p", "6379:6379", "-e", "SOMETHING=else", "--volume", "/var/lib/redis:/data", "--label", "service=app-redis", "--label", "cache=true", "redis:latest"], @redis.run "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=\"10m\" --publish 6379:6379 -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
new_command(:redis).run.join(" ")
assert_equal \
"docker run --name app-busybox --detach --restart unless-stopped --log-opt max-size=\"10m\" --label service=\"app-busybox\" busybox:latest",
new_command(:busybox).run.join(" ")
end
test "run with logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --name app-busybox --detach --restart unless-stopped --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app-busybox\" busybox:latest",
new_command(:busybox).run.join(" ")
end end
test "start" do test "start" do
assert_equal [:docker, :container, :start, "app-mysql"], @mysql.start assert_equal \
"docker container start app-mysql",
new_command(:mysql).start.join(" ")
end end
test "stop" do test "stop" do
assert_equal [:docker, :container, :stop, "app-mysql"], @mysql.stop assert_equal \
"docker container stop app-mysql",
new_command(:mysql).stop.join(" ")
end end
test "info" do test "info" do
assert_equal [:docker, :ps, "--filter", "label=service=app-mysql"], @mysql.info assert_equal \
"docker ps --filter label=service=app-mysql",
new_command(:mysql).info.join(" ")
end end
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
[ :docker, :run, "--rm", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "mysql:8.0", "mysql", "-u", "root" ], "docker run --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root",
@mysql.execute_in_new_container("mysql", "-u", "root") new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ")
end end
test "execute in existing container" do test "execute in existing container" do
assert_equal \ assert_equal \
[ :docker, :exec, "app-mysql", "mysql", "-u", "root" ], "docker exec app-mysql mysql -u root",
@mysql.execute_in_existing_container("mysql", "-u", "root") new_command(:mysql).execute_in_existing_container("mysql", "-u", "root").join(" ")
end end
test "execute in new container over ssh" do test "execute in new container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% mysql:8.0 mysql -u root|, assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" private.registry/mysql:8.0 mysql -u root|,
@mysql.execute_in_new_container_over_ssh("mysql", "-u", "root") new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root")
end end
end end
test "execute in existing container over ssh" do test "execute in existing container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r|docker exec -it app-mysql mysql -u root|, assert_match %r|docker exec -it app-mysql mysql -u root|,
@mysql.execute_in_existing_container_over_ssh("mysql", "-u", "root") new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root")
end end
end end
test "logs" do test "logs" do
assert_equal [:docker, :logs, "app-mysql", "-t", "2>&1"], @mysql.logs assert_equal \
assert_equal [:docker, :logs, "app-mysql", " --since 5m", " -n 100", "-t", "2>&1", "|", "grep 'thing'"], @mysql.logs(since: "5m", lines: 100, grep: "thing") "docker logs app-mysql --timestamps 2>&1",
new_command(:mysql).logs.join(" ")
assert_equal \
"docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'",
new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_equal "ssh -t root@1.1.1.5 'docker logs app-mysql -t -n 10 -f 2>&1'", @mysql.follow_logs assert_equal \
"ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
new_command(:mysql).follow_logs
end end
test "remove container" do test "remove container" do
assert_equal [:docker, :container, :prune, "-f", "--filter", "label=service=app-mysql"], @mysql.remove_container assert_equal \
"docker container prune --force --filter label=service=app-mysql",
new_command(:mysql).remove_container.join(" ")
end end
test "remove image" do test "remove image" do
assert_equal [:docker, :image, :prune, "-a", "-f", "--filter", "label=service=app-mysql"], @mysql.remove_image assert_equal \
"docker image rm --force private.registry/mysql:8.0",
new_command(:mysql).remove_image.join(" ")
end end
private
def new_command(accessory)
Mrsk::Commands::Accessory.new(Mrsk::Configuration.new(@config), name: accessory)
end
end end

View File

@@ -5,7 +5,6 @@ class CommandsAppTest < ActiveSupport::TestCase
ENV["RAILS_MASTER_KEY"] = "456" ENV["RAILS_MASTER_KEY"] = "456"
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config).tap { |c| c.version = "999" }
end end
teardown do teardown do
@@ -14,148 +13,262 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --name app-999 -e RAILS_MASTER_KEY=456 --label service=app --label role=web --label traefik.http.routers.app.rule='PathPrefix(`/`)' --label traefik.http.services.app.loadbalancer.healthcheck.path=/up --label traefik.http.services.app.loadbalancer.healthcheck.interval=1s --label traefik.http.middlewares.app.retry.attempts=3 --label traefik.http.middlewares.app.retry.initialinterval=500ms dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
@app.run.join(" ") new_command.run.join(" ")
end end
test "run with volumes" do test "run with volumes" do
@config[:volumes] = ["/local/path:/container/path" ] @config[:volumes] = ["/local/path:/container/path" ]
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --name app-999 -e RAILS_MASTER_KEY=456 --volume /local/path:/container/path --label service=app --label role=web --label traefik.http.routers.app.rule='PathPrefix(`/`)' --label traefik.http.services.app.loadbalancer.healthcheck.path=/up --label traefik.http.services.app.loadbalancer.healthcheck.interval=1s --label traefik.http.middlewares.app.retry.attempts=3 --label traefik.http.middlewares.app.retry.initialinterval=500ms dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
@app.run.join(" ") new_command.run.join(" ")
end
test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/healthz\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ")
end
test "run with custom options" do
@config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \
"docker run --detach --restart unless-stopped --name app-jobs-999 -e MRSK_CONTAINER_NAME=\"app-jobs-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs",
new_command(role: "jobs").run.join(" ")
end
test "run with logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ")
end end
test "start" do test "start" do
assert_equal \ assert_equal \
"docker start app-999", "docker start app-web-999",
@app.start.join(" ") new_command.start.join(" ")
end
test "start with destination" do
@destination = "staging"
assert_equal \
"docker start app-web-staging-999",
new_command.start.join(" ")
end end
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker stop", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop",
@app.stop.join(" ") new_command.stop.join(" ")
end
test "stop with custom stop wait time" do
@config[:stop_wait_time] = 30
assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop -t 30",
new_command.stop.join(" ")
end
test "stop with version" do
assert_equal \
"docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop",
new_command.stop(version: "123").join(" ")
end end
test "info" do test "info" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app", "docker ps --filter label=service=app --filter label=role=web",
@app.info.join(" ") new_command.info.join(" ")
end
test "info with destination" do
@destination = "staging"
assert_equal \
"docker ps --filter label=service=app --filter label=destination=staging --filter label=role=web",
new_command.info.join(" ")
end end
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs 2>&1",
@app.logs.join(" ") new_command.logs.join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m 2>&1",
@app.logs(since: "5m").join(" ") new_command.logs(since: "5m").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -n 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --tail 100 2>&1",
@app.logs(lines: "100").join(" ") new_command.logs(lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m -n 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m --tail 100 2>&1",
@app.logs(since: "5m", lines: "100").join(" ") new_command.logs(since: "5m", lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs 2>&1 | grep 'my-id'",
@app.logs(grep: "my-id").join(" ") new_command.logs(grep: "my-id").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m 2>&1 | grep 'my-id'",
@app.logs(since: "5m", grep: "my-id").join(" ") new_command.logs(since: "5m", grep: "my-id").join(" ")
end end
test "follow logs" do test "follow logs" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do assert_match \
assert_equal \ "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 --follow 2>&1",
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1", new_command.follow_logs(host: "app-1")
@app.follow_logs(host: "app-1")
assert_equal \ assert_match \
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1 | grep \"Completed\"", "docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 --follow 2>&1 | grep \"Completed\"",
@app.follow_logs(host: "app-1", grep: "Completed") new_command.follow_logs(host: "app-1", grep: "Completed")
end
end end
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails db:setup", "docker run --rm -e RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails db:setup",
@app.execute_in_new_container("bin/rails", "db:setup").join(" ") new_command.execute_in_new_container("bin/rails", "db:setup").join(" ")
end end
test "execute in existing container" do test "execute in existing container" do
assert_equal \ assert_equal \
"docker exec app-999 bin/rails db:setup", "docker exec app-web-999 bin/rails db:setup",
@app.execute_in_existing_container("bin/rails", "db:setup").join(" ") new_command.execute_in_existing_container("bin/rails", "db:setup").join(" ")
end end
test "execute in new container over ssh" do test "execute in new container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c|,
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails c|, new_command.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
@app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end
end end
test "execute in existing container over ssh" do test "execute in existing container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do assert_match %r|docker exec -it app-web-999 bin/rails c|,
assert_match %r|docker exec -it app-999 bin/rails c|, new_command.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
@app.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end
end end
test "run over ssh" do test "run over ssh" do
assert_equal "ssh -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "run over ssh with custom user" do test "run over ssh with custom user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app" } }) @config[:ssh] = { "user" => "app" }
assert_equal "ssh -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "run over ssh with proxy" do test "run over ssh with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "2.2.2.2" } }) @config[:ssh] = { "proxy" => "2.2.2.2" }
assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "run over ssh with proxy user" do test "run over ssh with proxy user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "app@2.2.2.2" } }) @config[:ssh] = { "proxy" => "app@2.2.2.2" }
assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "run over ssh with custom user with proxy" do test "run over ssh with custom user with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" } }) @config[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" }
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "current_container_id" do test "current_container_id" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app", "docker ps --quiet --filter label=service=app --filter label=role=web",
@app.current_container_id.join(" ") new_command.current_container_id.join(" ")
end
test "current_container_id with destination" do
@destination = "staging"
assert_equal \
"docker ps --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web",
new_command.current_container_id.join(" ")
end end
test "container_id_for" do test "container_id_for" do
assert_equal \ assert_equal \
"docker container ls -a -f name=app-999 -q", "docker container ls --all --filter name=^app-999$ --quiet",
@app.container_id_for(container_name: "app-999").join(" ") new_command.container_id_for(container_name: "app-999").join(" ")
end end
test "current_running_version" do test "current_running_version" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", "docker ps --filter label=service=app --filter label=role=web --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1",
@app.current_running_version.join(" ") new_command.current_running_version.join(" ")
end end
test "most_recent_version_from_available_images" do test "list_containers" do
assert_equal \ assert_equal \
"docker image ls --format \"{{.Tag}}\" dhh/app | head -n 1", "docker container ls --all --filter label=service=app --filter label=role=web",
@app.most_recent_version_from_available_images.join(" ") new_command.list_containers.join(" ")
end end
test "list_containers with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter label=service=app --filter label=destination=staging --filter label=role=web",
new_command.list_containers.join(" ")
end
test "list_container_names" do
assert_equal \
"docker container ls --all --filter label=service=app --filter label=role=web --format '{{ .Names }}'",
new_command.list_container_names.join(" ")
end
test "remove_container" do
assert_equal \
"docker container ls --all --filter name=^app-web-999$ --quiet | xargs docker container rm",
new_command.remove_container(version: "999").join(" ")
end
test "remove_container with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^app-web-staging-999$ --quiet | xargs docker container rm",
new_command.remove_container(version: "999").join(" ")
end
test "remove_containers" do
assert_equal \
"docker container prune --force --filter label=service=app --filter label=role=web",
new_command.remove_containers.join(" ")
end
test "remove_containers with destination" do
@destination = "staging"
assert_equal \
"docker container prune --force --filter label=service=app --filter label=destination=staging --filter label=role=web",
new_command.remove_containers.join(" ")
end
test "list_images" do
assert_equal \
"docker image ls dhh/app",
new_command.list_images.join(" ")
end
test "remove_images" do
assert_equal \
"docker image prune --all --force --filter label=service=app --filter label=role=web",
new_command.remove_images.join(" ")
end
test "remove_images with destination" do
@destination = "staging"
assert_equal \
"docker image prune --all --force --filter label=service=app --filter label=destination=staging --filter label=role=web",
new_command.remove_images.join(" ")
end
private
def new_command(role: "web")
Mrsk::Commands::App.new(Mrsk::Configuration.new(@config, destination: @destination, version: "999"), role: role)
end
end end

View File

@@ -0,0 +1,43 @@
require "test_helper"
class CommandsAuditorTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
audit_broadcast_cmd: "bin/audit_broadcast"
}
end
test "record" do
assert_match \
/echo '.* app removed container' >> mrsk-app-audit.log/,
new_command.record("app removed container").join(" ")
end
test "record with destination" do
@destination = "staging"
assert_match \
/echo '.* app removed container' >> mrsk-app-staging-audit.log/,
new_command.record("app removed container").join(" ")
end
test "record with role" do
@role = "web"
assert_match \
/echo '.* \[web\] app removed container' >> mrsk-app-audit.log/,
new_command.record("app removed container").join(" ")
end
test "broadcast" do
assert_match \
/bin\/audit_broadcast '\[.*\] app removed container'/,
new_command.broadcast("app removed container").join(" ")
end
private
def new_command
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"), role: @role)
end
end

View File

@@ -8,50 +8,82 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "target multiarch by default" do test "target multiarch by default" do
builder = new_builder_command builder = new_builder_command
assert_equal "multiarch", builder.name assert_equal "multiarch", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch", "-t", "dhh/app:123", "."], builder.push assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end end
test "target native when multiarch is off" do test "target native when multiarch is off" do
builder = new_builder_command(builder: { "multiarch" => false }) builder = new_builder_command(builder: { "multiarch" => false })
assert_equal "native", builder.name assert_equal "native", builder.name
assert_equal [:docker, :build, "-t", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123"], builder.push assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ")
end end
test "target multiarch remote when local and remote is set" do test "target multiarch remote when local and remote is set" do
builder = new_builder_command(builder: { "local" => { }, "remote" => { } }) builder = new_builder_command(builder: { "local" => { }, "remote" => { } })
assert_equal "multiarch/remote", builder.name assert_equal "multiarch/remote", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch-remote", "-t", "dhh/app:123", "."], builder.push assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end end
test "target native remote when only remote is set" do test "target native remote when only remote is set" do
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } }) builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } })
assert_equal "native/remote", builder.name assert_equal "native/remote", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "mrsk-app-native-remote", "-t", "dhh/app:123", "."], builder.push assert_equal \
"docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end end
test "build args" do test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal [ "--build-arg", "a=1", "--build-arg", "b=2" ], builder.target.build_args assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile",
builder.target.build_options.join(" ")
end end
test "build secrets" do test "build secrets" do
builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] }) builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] })
assert_equal [ "--secret", "id=token_a", "--secret", "id=token_b" ], builder.target.build_secrets assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
builder.target.build_options.join(" ")
end
test "build dockerfile" do
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz",
builder.target.build_options.join(" ")
end
test "build context" do
builder = new_builder_command(builder: { "context" => ".." })
assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..",
builder.push.join(" ")
end end
test "native push with build args" do test "native push with build args" do
builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } })
assert_equal [ :docker, :build, "-t", "--build-arg", "a=1", "--build-arg", "b=2", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123" ], builder.push assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ")
end end
test "multiarch push with build args" do test "multiarch push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal [ :docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch", "-t", "dhh/app:123", "--build-arg", "a=1", "--build-arg", "b=2", "." ], builder.push assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .",
builder.push.join(" ")
end end
test "native push with with build secrets" do test "native push with with build secrets" do
builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] }) builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
assert_equal [ :docker, :build, "-t", "--secret", "id=a", "--secret", "id=b", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123" ], builder.push assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile . && docker push dhh/app:123 && docker push dhh/app:latest",
builder.push.join(" ")
end end
private private

View File

@@ -0,0 +1,100 @@
require "test_helper"
class CommandsHealthcheckTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "run" do
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom port" do
@config[:healthcheck] = { "port" => 3001 }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" dhh/app:123",
new_command.run.join(" ")
end
test "run with destination" do
@destination = "staging"
assert_equal \
"docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging -e MRSK_CONTAINER_NAME=\"healthcheck-app-staging\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" --mount \"somewhere\" dhh/app:123",
new_command.run.join(" ")
end
test "curl" do
assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/up",
new_command.curl.join(" ")
end
test "curl with custom path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/healthz",
new_command.curl.join(" ")
end
test "stop" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker stop",
new_command.stop.join(" ")
end
test "stop with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker stop",
new_command.stop.join(" ")
end
test "remove" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker container rm",
new_command.remove.join(" ")
end
test "remove with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker container rm",
new_command.remove.join(" ")
end
test "logs" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker logs --tail 50 2>&1",
new_command.logs.join(" ")
end
test "logs with destination" do
@destination = "staging"
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-staging-123$ --quiet | xargs docker logs --tail 50 2>&1",
new_command.logs.join(" ")
end
private
def new_command
Mrsk::Commands::Healthcheck.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"))
end
end

View File

@@ -0,0 +1,33 @@
require "test_helper"
class CommandsLockTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "status" do
assert_equal \
"stat mrsk_lock > /dev/null && cat mrsk_lock/details | base64 -d",
new_command.status.join(" ")
end
test "acquire" do
assert_match \
/mkdir mrsk_lock && echo ".*" > mrsk_lock\/details/m,
new_command.acquire("Hello", "123").join(" ")
end
test "release" do
assert_match \
"rm mrsk_lock/details && rm -r mrsk_lock",
new_command.release.join(" ")
end
private
def new_command
Mrsk::Commands::Lock.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

@@ -0,0 +1,27 @@
require "test_helper"
class CommandsPruneTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "images" do
assert_equal \
"docker image prune --all --force --filter label=service=app --filter until=168h",
new_command.images.join(" ")
end
test "containers" do
assert_equal \
"docker container prune --force --filter label=service=app --filter until=72h",
new_command.containers.join(" ")
end
private
def new_command
Mrsk::Commands::Prune.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

@@ -14,10 +14,36 @@ class CommandsRegistryTest < ActiveSupport::TestCase
end end
test "registry login" do test "registry login" do
assert_equal [ :docker, :login, "hub.docker.com", "-u", "dhh", "-p", "secret" ], @registry.login assert_equal \
"docker login hub.docker.com -u dhh -p secret",
@registry.login.join(" ")
end
test "registry login with ENV password" do
ENV["MRSK_REGISTRY_PASSWORD"] = "more-secret"
@config[:registry]["password"] = [ "MRSK_REGISTRY_PASSWORD" ]
assert_equal \
"docker login hub.docker.com -u dhh -p more-secret",
@registry.login.join(" ")
ensure
ENV.delete("MRSK_REGISTRY_PASSWORD")
end
test "registry login with ENV username" do
ENV["MRSK_REGISTRY_USERNAME"] = "also-secret"
@config[:registry]["username"] = [ "MRSK_REGISTRY_USERNAME" ]
assert_equal \
"docker login hub.docker.com -u also-secret -p secret",
@registry.login.join(" ")
ensure
ENV.delete("MRSK_REGISTRY_USERNAME")
end end
test "registry logout" do test "registry logout" do
assert_equal [:docker, :logout, "hub.docker.com"], @registry.logout assert_equal \
"docker logout hub.docker.com",
@registry.logout.join(" ")
end end
end end

View File

@@ -2,71 +2,149 @@ require "test_helper"
class CommandsTraefikTest < ActiveSupport::TestCase class CommandsTraefikTest < ActiveSupport::TestCase
setup do setup do
@image = "traefik:test"
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
end end
test "run" do test "run" do
assert_equal \ assert_equal \
[:docker, :run, "--name traefik", "-d", "--restart unless-stopped", "-p 80:80", "-v /var/run/docker.sock:/var/run/docker.sock", "traefik", "--providers.docker", "--log.level=DEBUG", "--accesslog.format", "json", "--metrics.prometheus.buckets", "0.1,0.3,1.2,5.0"], "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run new_command.run.join(" ")
@config[:traefik]["host_port"] = "8080"
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with ports configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"publish" => %w[9000:9000 9001:9001]}
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --publish \"9000:9000\" --publish \"9001:9001\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with volumes configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with several options configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"}
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with labels configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run without configuration" do
@config.delete(:traefik)
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=DEBUG",
new_command.run.join(" ")
end
test "run with logging config" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end end
test "traefik start" do test "traefik start" do
assert_equal \ assert_equal \
[:docker, :container, :start, 'traefik'], new_command.start "docker container start traefik",
new_command.start.join(" ")
end end
test "traefik stop" do test "traefik stop" do
assert_equal \ assert_equal \
[:docker, :container, :stop, 'traefik'], new_command.stop "docker container stop traefik",
new_command.stop.join(" ")
end end
test "traefik info" do test "traefik info" do
assert_equal \ assert_equal \
[:docker, :ps, '--filter', 'name=traefik'], new_command.info "docker ps --filter name=^traefik$",
new_command.info.join(" ")
end end
test "traefik logs" do test "traefik logs" do
assert_equal \ assert_equal \
[:docker, :logs, 'traefik', '-t', '2>&1'], new_command.logs "docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ")
end end
test "traefik logs since 2h" do test "traefik logs since 2h" do
assert_equal \ assert_equal \
[:docker, :logs, 'traefik', ' --since 2h', '-t', '2>&1'], new_command.logs(since: '2h') "docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ")
end end
test "traefik logs last 10 lines" do test "traefik logs last 10 lines" do
assert_equal \ assert_equal \
[:docker, :logs, 'traefik', ' -n 10', '-t', '2>&1'], new_command.logs(lines: 10) "docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ")
end end
test "traefik logs with grep hello!" do test "traefik logs with grep hello!" do
assert_equal \ assert_equal \
[:docker, :logs, 'traefik', '-t', '2>&1', "|", "grep 'hello!'"], new_command.logs(grep: 'hello!') "docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ")
end end
test "traefik remove container" do test "traefik remove container" do
assert_equal \ assert_equal \
[:docker, :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik"], new_command.remove_container "docker container prune --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_container.join(" ")
end end
test "traefik remove image" do test "traefik remove image" do
assert_equal \ assert_equal \
[:docker, :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"], new_command.remove_image "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_image.join(" ")
end end
test "traefik follow logs" do test "traefik follow logs" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1'", new_command.follow_logs(host: @config[:servers].first) "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'",
new_command.follow_logs(host: @config[:servers].first)
end end
test "traefik follow logs with grep hello!" do test "traefik follow logs with grep hello!" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1 | grep \"hello!\"'", new_command.follow_logs(host: @config[:servers].first, grep: 'hello!') "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'",
new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end end
private private

View File

@@ -4,7 +4,10 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
setup do setup do
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ], servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => [ "1.1.1.3", "1.1.1.4" ]
},
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
accessories: { accessories: {
"mysql" => { "mysql" => {
@@ -29,7 +32,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
}, },
"redis" => { "redis" => {
"image" => "redis:latest", "image" => "redis:latest",
"host" => "1.1.1.6", "hosts" => [ "1.1.1.6", "1.1.1.7" ],
"port" => "6379:6379", "port" => "6379:6379",
"labels" => { "labels" => {
"cache" => true "cache" => true
@@ -39,7 +42,26 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
}, },
"volumes" => [ "volumes" => [
"/var/lib/redis:/data" "/var/lib/redis:/data"
] ],
"options" => {
"cpus" => 4,
"memory" => "2GB"
}
},
"monitoring" => {
"image" => "monitoring:latest",
"roles" => [ "web" ],
"port" => "4321:4321",
"labels" => {
"cache" => true
},
"env" => {
"STATSD_PORT" => "8126"
},
"options" => {
"cpus" => 4,
"memory" => "2GB"
}
} }
} }
} }
@@ -58,8 +80,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end end
test "host" do test "host" do
assert_equal "1.1.1.5", @config.accessory(:mysql).host assert_equal ["1.1.1.5"], @config.accessory(:mysql).hosts
assert_equal "1.1.1.6", @config.accessory(:redis).host assert_equal ["1.1.1.6", "1.1.1.7"], @config.accessory(:redis).hosts
assert_equal ["1.1.1.1", "1.1.1.2"], @config.accessory(:monitoring).hosts
end end
test "missing host" do test "missing host" do
@@ -67,25 +90,39 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
@config = Mrsk::Configuration.new(@deploy) @config = Mrsk::Configuration.new(@deploy)
assert_raises(ArgumentError) do assert_raises(ArgumentError) do
@config.accessory(:mysql).host @config.accessory(:mysql).hosts
end end
end end
test "setting host, hosts and roles" do
@deploy[:accessories]["mysql"]["hosts"] = true
@deploy[:accessories]["mysql"]["roles"] = true
@config = Mrsk::Configuration.new(@deploy)
exception = assert_raises(ArgumentError) do
@config.accessory(:mysql).hosts
end
assert_equal "Specify one of `host`, `hosts` or `roles` for accessory `mysql`", exception.message
end
test "label args" do test "label args" do
assert_equal ["--label", "service=app-mysql"], @config.accessory(:mysql).label_args assert_equal ["--label", "service=\"app-mysql\""], @config.accessory(:mysql).label_args
assert_equal ["--label", "service=app-redis", "--label", "cache=true"], @config.accessory(:redis).label_args assert_equal ["--label", "service=\"app-redis\"", "--label", "cache=\"true\""], @config.accessory(:redis).label_args
end end
test "env args with secret" do test "env args with secret" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
assert_equal ["-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%"], @config.accessory(:mysql).env_args
assert @config.accessory(:mysql).env_args[1].is_a?(SSHKit::Redaction) @config.accessory(:mysql).env_args.tap do |env_args|
assert_equal ["-e", "MYSQL_ROOT_PASSWORD=\"secret123\"", "-e", "MYSQL_ROOT_HOST=\"%\""], Mrsk::Utils.unredacted(env_args)
assert_equal ["-e", "MYSQL_ROOT_PASSWORD=[REDACTED]", "-e", "MYSQL_ROOT_HOST=\"%\""], Mrsk::Utils.redacted(env_args)
end
ensure ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil ENV["MYSQL_ROOT_PASSWORD"] = nil
end end
test "env args without secret" do test "env args without secret" do
assert_equal ["-e", "SOMETHING=else"], @config.accessory(:redis).env_args assert_equal ["-e", "SOMETHING=\"else\""], @config.accessory(:redis).env_args
end end
test "volume args" do test "volume args" do
@@ -104,4 +141,8 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
test "directories" do test "directories" do
assert_equal({"$PWD/app-mysql/data"=>"/var/lib/mysql"}, @config.accessory(:mysql).directories) assert_equal({"$PWD/app-mysql/data"=>"/var/lib/mysql"}, @config.accessory(:mysql).directories)
end end
test "options" do
assert_equal ["--cpus", "\"4\"", "--memory", "\"2GB\""], @config.accessory(:redis).option_args
end
end end

View File

@@ -38,11 +38,11 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "label args" do test "label args" do
assert_equal [ "--label", "service=app", "--label", "role=workers" ], @config_with_roles.role(:workers).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"" ], @config_with_roles.role(:workers).label_args
end end
test "special label args for web" do test "special label args for web" do
assert_equal [ "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms"], @config.role(:web).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-web-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\"" ], @config.role(:web).label_args
end end
test "custom labels" do test "custom labels" do
@@ -57,8 +57,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "overwriting default traefik label" do test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app.rule" => "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'" } @deploy[:labels] = { "traefik.http.routers.app.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" }
assert_equal "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'", @config.role(:web).labels["traefik.http.routers.app.rule"] assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", @config.role(:web).labels["traefik.http.routers.app.rule"]
end end
test "default traefik label on non-web role" do test "default traefik label on non-web role" do
@@ -66,12 +66,20 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] } c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] }
}) })
assert_equal [ "--label", "service=app", "--label", "role=beta", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms" ], config.role(:beta).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-beta.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-beta.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args
end
test "default traefik label for non-web role with destination" do
config = Mrsk::Configuration.new(@deploy_with_roles.tap { |c|
c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] }
}, destination: "staging")
assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination=\"staging\"", "--label", "traefik.http.routers.app-beta-staging.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-beta-staging.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-beta-staging.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-beta-staging-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-staging-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta-staging.middlewares=\"app-beta-staging-retry@docker\"" ], config.role(:beta).label_args
end end
test "env overwritten by role" do test "env overwritten by role" do
assert_equal "redis://a/b", @config_with_roles.role(:workers).env["REDIS_URL"] assert_equal "redis://a/b", @config_with_roles.role(:workers).env["REDIS_URL"]
assert_equal ["-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args assert_equal ["-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], @config_with_roles.role(:workers).env_args
end end
test "env secret overwritten by role" do test "env secret overwritten by role" do
@@ -95,9 +103,12 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
ENV["DB_PASSWORD"] = "secret123" ENV["DB_PASSWORD"] = "secret&\"123"
assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args @config_with_roles.role(:workers).env_args.tap do |env_args|
assert_equal ["-e", "REDIS_PASSWORD=\"secret456\"", "-e", "DB_PASSWORD=\"secret&\\\"123\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.unredacted(env_args)
assert_equal ["-e", "REDIS_PASSWORD=[REDACTED]", "-e", "DB_PASSWORD=[REDACTED]", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.redacted(env_args)
end
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
@@ -116,7 +127,10 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
ENV["DB_PASSWORD"] = "secret123" ENV["DB_PASSWORD"] = "secret123"
assert_equal ["-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args @config_with_roles.role(:workers).env_args.tap do |env_args|
assert_equal ["-e", "DB_PASSWORD=\"secret123\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.unredacted(env_args)
assert_equal ["-e", "DB_PASSWORD=[REDACTED]", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.redacted(env_args)
end
ensure ensure
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
end end
@@ -133,7 +147,10 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args @config_with_roles.role(:workers).env_args.tap do |env_args|
assert_equal ["-e", "REDIS_PASSWORD=\"secret456\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.unredacted(env_args)
assert_equal ["-e", "REDIS_PASSWORD=[REDACTED]", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], Mrsk::Utils.redacted(env_args)
end
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
end end

View File

@@ -3,6 +3,7 @@ require "test_helper"
class ConfigurationTest < ActiveSupport::TestCase class ConfigurationTest < ActiveSupport::TestCase
setup do setup do
ENV["RAILS_MASTER_KEY"] = "456" ENV["RAILS_MASTER_KEY"] = "456"
ENV["VERSION"] = "missing"
@deploy = { @deploy = {
service: "app", image: "dhh/app", service: "app", image: "dhh/app",
@@ -15,23 +16,29 @@ class ConfigurationTest < ActiveSupport::TestCase
@config = Mrsk::Configuration.new(@deploy) @config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({ @deploy_with_roles = @deploy.dup.merge({
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ] } } }) servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.1", "1.1.1.3" ] } } })
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles) @config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end end
teardown do teardown do
ENV["RAILS_MASTER_KEY"] = nil ENV.delete("RAILS_MASTER_KEY")
ENV.delete("VERSION")
end end
test "ensure valid keys" do %i[ service image registry ].each do |key|
assert_raise(ArgumentError) do test "#{key} config required" do
Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) }) assert_raise(ArgumentError) do
Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) }) Mrsk::Configuration.new @deploy.tap { _1.delete key }
Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) }) end
end
end
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") }) %w[ username password ].each do |key|
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") }) test "registry #{key} required" do
assert_raise(ArgumentError) do
Mrsk::Configuration.new @deploy.tap { _1[:registry].delete key }
end
end end
end end
@@ -48,7 +55,7 @@ class ConfigurationTest < ActiveSupport::TestCase
test "all hosts" do test "all hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2"], @config.all_hosts assert_equal [ "1.1.1.1", "1.1.1.2"], @config.all_hosts
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.all_hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], @config_with_roles.all_hosts
end end
test "primary web host" do test "primary web host" do
@@ -62,12 +69,24 @@ class ConfigurationTest < ActiveSupport::TestCase
@deploy_with_roles[:servers]["workers"]["traefik"] = true @deploy_with_roles[:servers]["workers"]["traefik"] = true
config = Mrsk::Configuration.new(@deploy_with_roles) config = Mrsk::Configuration.new(@deploy_with_roles)
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config.traefik_hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.traefik_hosts
end end
test "version" do test "version" do
assert_equal "missing", @config.version ENV.delete("VERSION")
assert_equal "123", Mrsk::Configuration.new(@deploy, version: "123").version
@config.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @config.version}
assert_match /no git repository found/, error.message
@config.expects(:current_commit_hash).returns("git-version")
assert_equal "git-version", @config.version
ENV["VERSION"] = "env-version"
assert_equal "env-version", @config.version
@config.version = "arg-version"
assert_equal "arg-version", @config.version
end end
test "repository" do test "repository" do
@@ -89,17 +108,18 @@ class ConfigurationTest < ActiveSupport::TestCase
end end
test "env args" do test "env args" do
assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args assert_equal [ "-e", "REDIS_URL=\"redis://x/y\"" ], @config.env_args
end end
test "env args with clear and secrets" do test "env args with clear and secrets" do
ENV["PASSWORD"] = "secret123" ENV["PASSWORD"] = "secret123"
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({ config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "clear" => { "PORT" => "3000" }, "secret" => [ "PASSWORD" ] } env: { "clear" => { "PORT" => "3000" }, "secret" => [ "PASSWORD" ] }
}) }) }) })
assert_equal [ "-e", "PASSWORD=secret123", "-e", "PORT=3000" ], config.env_args assert_equal [ "-e", "PASSWORD=\"secret123\"", "-e", "PORT=\"3000\"" ], Mrsk::Utils.unredacted(config.env_args)
assert config.env_args[1].is_a?(SSHKit::Redaction) assert_equal [ "-e", "PASSWORD=[REDACTED]", "-e", "PORT=\"3000\"" ], Mrsk::Utils.redacted(config.env_args)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
end end
@@ -109,17 +129,18 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "clear" => { "PORT" => "3000" } } env: { "clear" => { "PORT" => "3000" } }
}) }) }) })
assert_equal [ "-e", "PORT=3000" ], config.env_args assert_equal [ "-e", "PORT=\"3000\"" ], config.env_args
end end
test "env args with only secrets" do test "env args with only secrets" do
ENV["PASSWORD"] = "secret123" ENV["PASSWORD"] = "secret123"
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({ config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] } env: { "secret" => [ "PASSWORD" ] }
}) }) }) })
assert_equal [ "-e", "PASSWORD=secret123" ], config.env_args assert_equal [ "-e", "PASSWORD=\"secret123\"" ], Mrsk::Utils.unredacted(config.env_args)
assert config.env_args[1].is_a?(SSHKit::Redaction) assert_equal [ "-e", "PASSWORD=[REDACTED]" ], Mrsk::Utils.redacted(config.env_args)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
end end
@@ -134,6 +155,39 @@ class ConfigurationTest < ActiveSupport::TestCase
test "valid config" do test "valid config" do
assert @config.valid? assert @config.valid?
assert @config_with_roles.valid?
end
test "hosts required for all roles" do
# Empty server list for implied web role
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: [])
end
# Empty server list
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: { "web" => [] })
end
# Missing hosts key
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: { "web" => {} })
end
# Empty hosts list
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => [] } })
end
# Nil hosts
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => nil } })
end
# One role with hosts, one without
assert_raises(ArgumentError) do
Mrsk::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } })
end
end end
test "ssh options" do test "ssh options" do
@@ -157,18 +211,32 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args
end end
test "logging args default" do
assert_equal ["--log-opt", "max-size=\"10m\""], @config.logging_args
end
test "logging args with configured options" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "options" => { "max-size" => "100m", "max-file" => 5 } }) })
assert_equal ["--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\""], @config.logging_args
end
test "logging args with configured driver and options" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => 5 } }) })
assert_equal ["--log-driver", "\"local\"", "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\""], @config.logging_args
end
test "erb evaluation of yml config" do test "erb evaluation of yml config" do
config = Mrsk::Configuration.create_from Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) config = Mrsk::Configuration.create_from config_file: Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__))
assert_equal "my-user", config.registry["username"] assert_equal "my-user", config.registry["username"]
end end
test "destination yml config merge" do test "destination yml config merge" do
dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__))
config = Mrsk::Configuration.create_from dest_config_file, destination: "world" config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "world"
assert_equal "1.1.1.1", config.all_hosts.first assert_equal "1.1.1.1", config.all_hosts.first
config = Mrsk::Configuration.create_from dest_config_file, destination: "mars" config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "mars"
assert_equal "1.1.1.3", config.all_hosts.first assert_equal "1.1.1.3", config.all_hosts.first
end end
@@ -176,11 +244,11 @@ class ConfigurationTest < ActiveSupport::TestCase
dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__))
assert_raises(RuntimeError) do assert_raises(RuntimeError) do
config = Mrsk::Configuration.create_from dest_config_file, destination: "missing" config = Mrsk::Configuration.create_from config_file: dest_config_file, destination: "missing"
end end
end end
test "to_h" do test "to_h" do
assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=redis://x/y"], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"] }, @config.to_h) assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=\"redis://x/y\""], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"], :logging=>["--log-opt", "max-size=\"10m\""], :healthcheck=>{"path"=>"/up", "port"=>3000, "max_attempts" => 7 }}, @config.to_h)
end end
end end

8
test/fixtures/deploy_simple.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
service: app
image: dhh/app
servers:
- "1.1.1.1"
- "1.1.1.2"
registry:
username: user
password: pw

View File

@@ -1,8 +1,12 @@
service: app service: app
image: dhh/app image: dhh/app
servers: servers:
- 1.1.1.1 web:
- 1.1.1.2 - "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry: registry:
username: user username: user
password: pw password: pw
@@ -23,7 +27,10 @@ accessories:
- data:/var/lib/mysql - data:/var/lib/mysql
redis: redis:
image: redis:latest image: redis:latest
host: 1.1.1.4 roles:
- web
port: 6379 port: 6379
directories: directories:
- data:/data - data:/data
readiness_delay: 0

View File

@@ -5,8 +5,9 @@ servers:
- 1.1.1.1 - 1.1.1.1
- 1.1.1.2 - 1.1.1.2
workers: workers:
- 1.1.1.3 hosts:
- 1.1.1.4 - 1.1.1.3
- 1.1.1.4
env: env:
REDIS_URL: redis://x/y REDIS_URL: redis://x/y
registry: registry:

View File

@@ -9,7 +9,18 @@ require "mrsk"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]
# Applies to remote commands only.
SSHKit.config.backend = SSHKit::Backend::Printer SSHKit.config.backend = SSHKit::Backend::Printer
# Ensure local commands use the printer backend too.
# See https://github.com/capistrano/sshkit/blob/master/lib/sshkit/dsl.rb#L9
module SSHKit
module DSL
def run_locally(&block)
SSHKit::Backend::Printer.new(SSHKit::Host.new(:local), &block).run
end
end
end
class ActiveSupport::TestCase class ActiveSupport::TestCase
end end

53
test/utils_test.rb Normal file
View File

@@ -0,0 +1,53 @@
require "test_helper"
class UtilsTest < ActiveSupport::TestCase
test "argumentize" do
assert_equal [ "--label", "foo=\"\\`bar\\`\"", "--label", "baz=\"qux\"", "--label", :quux ], \
Mrsk::Utils.argumentize("--label", { foo: "`bar`", baz: "qux", quux: nil })
end
test "argumentize with redacted" do
assert_kind_of SSHKit::Redaction, \
Mrsk::Utils.argumentize("--label", { foo: "bar" }, sensitive: true).last
end
test "argumentize_env_with_secrets" do
ENV.expects(:fetch).with("FOO").returns("secret")
args = Mrsk::Utils.argumentize_env_with_secrets({ "secret" => [ "FOO" ], "clear" => { BAZ: "qux" } })
assert_equal [ "-e", "FOO=[REDACTED]", "-e", "BAZ=\"qux\"" ], Mrsk::Utils.redacted(args)
assert_equal [ "-e", "FOO=\"secret\"", "-e", "BAZ=\"qux\"" ], Mrsk::Utils.unredacted(args)
end
test "optionize" do
assert_equal [ "--foo", "\"bar\"", "--baz", "\"qux\"", "--quux" ], \
Mrsk::Utils.optionize({ foo: "bar", baz: "qux", quux: true })
end
test "optionize with" do
assert_equal [ "--foo=\"bar\"", "--baz=\"qux\"", "--quux" ], \
Mrsk::Utils.optionize({ foo: "bar", baz: "qux", quux: true }, with: "=")
end
test "no redaction from #to_s" do
assert_equal "secret", Mrsk::Utils.sensitive("secret").to_s
end
test "redact from #inspect" do
assert_equal "[REDACTED]".inspect, Mrsk::Utils.sensitive("secret").inspect
end
test "redact from SSHKit output" do
assert_kind_of SSHKit::Redaction, Mrsk::Utils.sensitive("secret")
end
test "redact from YAML output" do
assert_equal "--- ! '[REDACTED]'\n", YAML.dump(Mrsk::Utils.sensitive("secret"))
end
test "escape_shell_value" do
assert_equal "\"foo\"", Mrsk::Utils.escape_shell_value("foo")
assert_equal "\"\\`foo\\`\"", Mrsk::Utils.escape_shell_value("`foo`")
end
end