Compare commits

...

268 Commits

Author SHA1 Message Date
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
71 changed files with 3078 additions and 797 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 }}

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.byebug_history .byebug_history
*.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,11 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.7.2) mrsk (0.10.0)
activesupport (>= 7.0) activesupport (>= 7.0)
bcrypt_pbkdf (~> 1.0)
dotenv (~> 2.8) dotenv (~> 2.8)
ed25519 (~> 1.2)
sshkit (~> 1.21) sshkit (~> 1.21)
thor (~> 1.2) thor (~> 1.2)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
@@ -11,88 +13,79 @@ 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.1)
irb (>= 1.5.0)
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)
irb (1.6.2)
reline (>= 0.3.0)
loofah (2.19.1) loofah (2.19.1)
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)
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

344
README.md
View File

@@ -1,10 +1,26 @@
# 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. It was built for Rails applications, but works with any type of web app that can be bundled with Docker. 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
## 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 ${SHELL}rc 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 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,13 +30,14 @@ 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
``` ```
Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app). Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app).
Now you're ready to deploy to the servers: Now you're ready to deploy to the servers:
@@ -30,37 +47,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. Ensure your app responds with `200 OK` to `GET /up`. 8. Ensure your app responds with `200 OK` to `GET /up`.
9. Stop any containers running a previous versions of the app. 9. Start a new container with the version of the app that matches the current git version hash.
10. Start a new container with the version of the app that matches the current git version hash. 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. 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.
## Vision ## Vision
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 our own hardware, or even just have a clear migration path to do so, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole! 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!
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 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. 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 structure also 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's a lot of compelling options available. 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, though. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but from an early stage when those concepts are familiar. 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? ## Why not just run Capistrano, Kubernetes or Docker Swarm?
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. 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. 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 to around imperative commands, like Capistrano. 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
@@ -73,6 +102,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`:
@@ -80,10 +174,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`:
@@ -148,6 +246,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:
@@ -182,12 +286,12 @@ 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.rule: Host(`app.hey.com`)
``` ```
Note: The escaped backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash! 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.
@@ -208,9 +312,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:
@@ -248,9 +396,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:
@@ -284,6 +452,55 @@ traefik:
This will start the traefik container with `--accesslog=true accesslog.format=json`. This will start the traefik container with `--accesslog=true accesslog.format=json`.
### Traefik's host port binding
By default Traefik binds to port 80 of the host machine, it can be configured to use an alternative port:
```yaml
traefik:
host_port: 8080
```
### Configure docker options for traefik
We allow users to pass additional docker options to the trafik container like
```yaml
traefik:
options:
publish:
- 8080:8080
volumes:
- /tmp/example.json:/tmp/example.json
memory: 512m
```
This will start the traefik container with a command like: `docker run ... --volume /tmp/example.json:/tmp/example.json --publish 8080:8080 `
### Configure alternate entrypoints for traefik
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
Build arguments that aren't secret can also be configured: Build arguments that aren't secret can also be configured:
@@ -297,14 +514,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:
@@ -319,32 +535,58 @@ 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 role:
- 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 ### Using audit broadcasts
@@ -447,7 +689,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`:
@@ -495,9 +737,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

@@ -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,98 +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("Booted #{name} accessory"), 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
audit_broadcast "Booted accessory #{name}"
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|
accessory.files.each do |(local, remote)| on(accessory.hosts) do
accessory.ensure_local_file_present(local) accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local)
execute *accessory.make_directory_for(remote) execute *accessory.make_directory_for(remote)
upload! local, remote upload! local, remote
execute :chmod, "755", remote execute :chmod, "755", remote
end
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|
accessory.directories.keys.each do |host_path| on(accessory.hosts) do
execute *accessory.make_directory(host_path) accessory.directories.keys.each do |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("Started #{name} accessory"), 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("Stopped #{name} accessory"), 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)
@@ -108,14 +123,14 @@ 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("Executed cmd '#{cmd}' on #{name} accessory"), 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("Executed cmd '#{cmd}' on #{name} accessory"), 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
@@ -123,7 +138,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)"
@@ -134,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
@@ -142,52 +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("Remove #{name} accessory 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("Removed #{name} accessory 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 *accessory.remove_service_directory on(accessory.hosts) do
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("Booted app 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("Rebooted app 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("Started app 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("Stopped app"), 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,14 +98,18 @@ 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("Executed cmd '#{cmd}' on app 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("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
@@ -91,21 +119,21 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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("Removed app container with version #{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("Removed all app 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("Removed all app 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

@@ -6,6 +6,8 @@ module Mrsk::Cli
class Base < Thor class Base < Thor
include SSHKit::DSL include SSHKit::DSL
class LockError < StandardError; end
def self.exit_on_failure?() true end def self.exit_on_failure?() true end
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
@@ -17,13 +19,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 +39,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,6 +53,15 @@ 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
@@ -68,5 +77,35 @@ module Mrsk::Cli
def audit_broadcast(line) def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug } run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end end
def with_lock
acquire_lock
yield
ensure
release_lock
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", []
end
raise LockError, "Deploy lock found"
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("Pulled image with version #{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

@@ -1,21 +1,42 @@
class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base
desc "perform", "Health check the current version of the app" MAX_ATTEMPTS = 7
class HealthcheckError < StandardError; end
default_command :perform
desc "perform", "Health check current app version"
def perform def perform
on(MRSK.primary_host) do on(MRSK.primary_host) do
begin begin
execute *MRSK.healthcheck.run execute *MRSK.healthcheck.run
target = "Health check against #{MRSK.config.healthcheck["path"]}" target = "Health check against #{MRSK.config.healthcheck["path"]}"
attempt = 1
if capture_with_info(*MRSK.healthcheck.curl) == "200" begin
info "#{target} succeeded with 200 OK!" status = capture_with_info(*MRSK.healthcheck.curl)
else
# Catches 1xx, 2xx, 3xx if status == "200"
raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!" 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..."
sleep attempt
attempt += 1
retry
else
raise
end
end end
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed, HealthcheckError => e
error capture_with_info(*MRSK.healthcheck.logs)
if e.message =~ /curl/ if e.message =~ /curl/
# Catches 4xx, 5xx
raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!" raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!"
else else
raise raise

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 "Set 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 "Removed 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,74 +1,108 @@
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
runtime = 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
say "Ensure app can pass healthcheck...", :magenta say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:healthcheck:perform" invoke "mrsk:cli:traefik:boot", [], invoke_options
invoke "mrsk:cli:app:boot" say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform", [], invoke_options
say "Prune old containers and images...", :magenta invoke "mrsk:cli:app:boot", [], invoke_options
invoke "mrsk:cli:prune:all"
end
audit_broadcast "Deployed app in #{runtime.to_i} seconds" say "Prune old containers and images...", :magenta
end invoke "mrsk:cli:prune:all", [], invoke_options
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def redeploy
runtime = print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot"
end
audit_broadcast "Redeployed app in #{runtime.to_i} seconds"
end
desc "rollback [VERSION]", "Rollback the app to VERSION"
def rollback(version)
MRSK.version = version
if container_name_available?(MRSK.config.service_with_version)
say "Stop current version, then start version #{version}...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start
end end
audit_broadcast "Rolled back app to version #{version}" audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" 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
desc "details", "Display details about Traefik and app containers" 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
with_lock do
invoke_options = deploy_options
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
desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version)
with_lock do
MRSK.config.version = version
if container_name_available?(MRSK.config.service_with_version)
say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
cli = self
old_version = nil
on(MRSK.hosts) do |host|
old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence
execute *MRSK.app.start
if old_version
sleep MRSK.config.readiness_delay
execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false
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
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"
@@ -82,7 +116,7 @@ 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.config.to_h.to_yaml
@@ -112,8 +146,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
@@ -132,46 +168,63 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600) File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)
end end
desc "remove", "Remove Traefik, app, and registry session from servers" 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 def remove
invoke "mrsk:cli:traefik:remove" with_lock do
invoke "mrsk:cli:app:remove" 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:registry:logout" 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
desc "version", "Display the MRSK version" desc "version", "Show 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 the application" desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck 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 private
def container_name_available?(container_name, host: MRSK.primary_host) def container_name_available?(container_name, host: MRSK.primary_host)
container_names = nil container_names = nil
on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") } on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") }
Array(container_names).include?(container_name) Array(container_names).include?(container_name)
end 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
images
end
end end
desc "images", "Prune unused images older than 7 days" desc "images", "Prune unused images older than 7 days"
def images def images
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("Pruned images"), verbosity: :debug on(MRSK.hosts) do
execute *MRSK.prune.images execute *MRSK.auditor.record("Pruned images"), verbosity: :debug
execute *MRSK.prune.images
end
end end
end end
desc "containers", "Prune stopped containers for the service older than 3 days" desc "containers", "Prune stopped containers older than 3 days"
def containers def containers
on(MRSK.hosts) do with_lock do
execute *MRSK.auditor.record("Pruned 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,49 @@
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) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false }
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("Started traefik"), 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("Stopped traefik"), 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 +74,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("Removed traefik 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("Removed traefik 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,38 +67,41 @@ 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
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end end
def healthcheck def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config) @healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end 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 = self.verbosity old_level = self.verbosity
@@ -90,26 +115,7 @@ class Mrsk::Commander
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"] || current_commit_hash
end
def 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
# 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,14 +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",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", *config.logging_args,
"-p", port, *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
@@ -35,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
@@ -73,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
@@ -96,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,36 +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",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", "--name", container_name,
"--name", service_with_version, "-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
@@ -38,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
@@ -48,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
@@ -72,32 +83,23 @@ 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
def container_id_for_version(version)
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
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end
def all_versions_from_available_containers
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end
def list_containers def list_containers
docker :container, :ls, "-a", *service_filter docker :container, :ls, "--all", *filter_args
end end
def list_container_names def list_container_names
@@ -106,12 +108,16 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
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
@@ -119,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,6 +1,13 @@
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 # Runs remotely
def record(line) def record(line)
append \ append \
@@ -21,22 +28,30 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
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_record_line(line) def tagged_record_line(line)
"'#{recorded_at_tag} #{performer_tag} #{line}'" tagged_line recorded_at_tag, performer_tag, role_tag, line
end end
def tagged_broadcast_line(line) def tagged_broadcast_line(line)
"'#{performer_tag} #{line}'" tagged_line performer_tag, role_tag, line
end
def tagged_line(*tags_and_line)
"'#{tags_and_line.compact.join(" ")}'"
end
def recorded_at_tag
"[#{Time.now.to_fs(:db)}]"
end end
def performer_tag def performer_tag
"[#{`whoami`.strip}]" "[#{`whoami`.strip}]"
end end
def recorded_at_tag def role_tag
"[#{Time.now.to_fs(:db)}]" "[#{role}]" if role
end end
end end

View File

@@ -1,8 +1,6 @@
module Mrsk::Commands module Mrsk::Commands
class Base class Base
delegate :redact, to: Mrsk::Utils delegate :redact, :argumentize, to: Mrsk::Utils
MAX_LOG_SIZE = "10m"
attr_accessor :config attr_accessor :config
@@ -18,7 +16,7 @@ module Mrsk::Commands
end end
def container_id_for(container_name:) def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q" docker :container, :ls, "--all", "--filter", "name=^#{container_name}$", "--quiet"
end end
private private
@@ -41,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,12 +1,21 @@
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_options def build_options
[ *build_tags, *build_labels, *build_args, *build_secrets ] [ *build_tags, *build_labels, *build_args, *build_secrets, *build_dockerfile ]
end
def build_context
context
end end
private private
@@ -26,6 +35,10 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end end
def build_dockerfile
argumentize "--file", dockerfile
end
def args def args
(config.builder && config.builder["args"]) || {} (config.builder && config.builder["args"]) || {}
end end
@@ -33,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

@@ -13,7 +13,7 @@ class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
"--platform", "linux/amd64,linux/arm64", "--platform", "linux/amd64,linux/arm64",
"--builder", builder_name, "--builder", builder_name,
*build_options, *build_options,
"." build_context
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, *build_options, "."), 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

@@ -17,7 +17,7 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
"--platform", platform, "--platform", platform,
"--builder", builder_name, "--builder", builder_name,
*build_options, *build_options,
"." build_context
end end
def info def info

View File

@@ -5,39 +5,45 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
web = config.role(:web) web = config.role(:web)
docker :run, docker :run,
"-d", "--detach",
"--name", container_name_with_version, "--name", container_name_with_version,
"-p", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}", "--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}",
"--label", "service=#{container_name}", "--label", "service=#{container_name}",
"-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"",
*web.env_args, *web.env_args,
*config.volume_args, *config.volume_args,
*web.option_args,
config.absolute_image, config.absolute_image,
web.cmd web.cmd
end end
def curl def curl
[ :curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", health_url ] [ :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 end
def stop def stop
pipe \ pipe container_id, xargs(docker(:stop))
container_id_for(container_name: container_name),
xargs(docker(:stop))
end end
def remove def remove
pipe \ pipe container_id, xargs(docker(:container, :rm))
container_id_for(container_name: container_name),
xargs(docker(:container, :rm))
end end
private private
def container_name def container_name
"healthcheck-#{config.service}" [ "healthcheck", config.service, config.destination ].compact.join("-")
end end
def container_name_with_version def container_name_with_version
"healthcheck-#{config.service_with_version}" "#{container_name}-#{config.version}"
end
def container_id
container_id_for(container_name: container_name_with_version)
end end
def health_url def health_url

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,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", redact(lookup("username")), "-p", redact(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,15 +1,21 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
delegate :optionize, to: Mrsk::Utils
IMAGE = "traefik:v2.9.9"
CONTAINER_PORT = 80
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
"-d", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", "--publish", port,
"-p 80:80", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"-v /var/run/docker.sock:/var/run/docker.sock", *config.logging_args,
"traefik", *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
@@ -21,32 +27,48 @@ 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 docker_options_args
(config.raw_config.dig(:traefik, "args") || { }).collect { |(key, value)| [ "--#{key}", value ] }.flatten 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
@@ -107,6 +121,15 @@ 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?
@@ -136,6 +159,9 @@ class Mrsk::Configuration
{ "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {}) { "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {})
end 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
@@ -156,10 +182,14 @@ class Mrsk::Configuration
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 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
@@ -176,6 +206,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
@@ -190,4 +226,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,22 +59,27 @@ 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.#{config.service}.rule" => "PathPrefix(`/`)",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"], "traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3", "traefik.http.middlewares.#{config.service}-retry.retry.attempts" => "5",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" "traefik.http.middlewares.#{config.service}-retry.retry.initialinterval" => "500ms",
"traefik.http.routers.#{config.service}.middlewares" => "#{config.service}-retry@docker"
} }
else else
{} {}

View File

@@ -1,13 +1,14 @@
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, redacted: 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}" ] escaped_pair = [ key, escape_shell_value(value) ].join("=")
[ argument, redacted ? redact(escaped_pair) : escaped_pair ]
else else
[ argument, k ] [ argument, key ]
end end
end end
end end
@@ -22,8 +23,34 @@ module Mrsk::Utils
end end
end end
# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
def optionize(args, with: nil)
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
# Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes # Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes
def redact(arg) # Used in execute_command to hide redact() args a user passes in def redact(arg) # Used in execute_command to hide redact() args a user passes in
arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc
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

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.7.2" VERSION = "0.10.0"
end end

View File

@@ -17,4 +17,10 @@ Gem::Specification.new do |spec|
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 --log-opt max-size=10m -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,13 +8,14 @@ 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

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...", output
assert_match "Health check against /up failed to respond, retrying in 2s...", 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,33 +1,259 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliMainTest < CliTestCase class CliMainTest < CliTestCase
test "version" do test "setup" do
version = stdouted { Mrsk::Cli::Main.new.version } Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:server:bootstrap")
assert_equal Mrsk::VERSION, version 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 "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 end
test "rollback bad version" do test "rollback bad version" do
run_command("details") # Preheat MRSK const run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output| run_command("rollback", "nonsense").tap do |output|
assert_match /docker container ls -a --filter label=service=app --format '{{ .Names }}'/, output assert_match /docker container ls --all --filter label=service=app --format '{{ .Names }}'/, output
assert_match /The app version 'nonsense' is not available as a container/, output assert_match /The app version 'nonsense' is not available as a container/, output
end end
end end
test "rollback good version" do test "rollback good version" do
Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true) Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").at_least_once
run_command("rollback", "123").tap do |output| run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output|
assert_match /Stop current version, then start version 123/, output assert_match "Start version 123", output
assert_match /docker ps -q --filter label=service=app | xargs docker stop/, output assert_match "docker start app-123", output
assert_match /docker start app-123/, output assert_match "docker container ls --all --filter name=^app-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
end end
end end
test "rollback without old version" do
Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--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-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
version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version
end
private private
def run_command(*command) def run_command(*command, config_file: "deploy_simple")
stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/#{config_file}.yml"]) }
end 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

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

@@ -0,0 +1,85 @@
require_relative "cli_test_case"
class CliTraefikTest < CliTestCase
test "boot" do
run_command("boot").tap do |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\" traefik:v2.9.9 --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,38 +2,41 @@ 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
assert_equal Mrsk::Configuration, @mrsk.config.class assert_equal Mrsk::Configuration, @mrsk.config.class
end end
test "commit hash as version" do
assert_equal `git rev-parse HEAD`.strip, @mrsk.config.version
end
test "commit hash as version but not in git" do
@mrsk.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @mrsk.config }
assert_match /no git repository found/, error.message
end
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
@@ -42,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,56 +49,68 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name app-mysql -d --restart unless-stopped --log-opt max-size=10m -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% --label service=app-mysql mysql:8.0", "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",
@mysql.run.join(" ") new_command(:mysql).run.join(" ")
assert_equal \ assert_equal \
"docker run --name app-redis -d --restart unless-stopped --log-opt max-size=10m -p 6379:6379 -e SOMETHING=else --volume /var/lib/redis:/data --label service=app-redis --label cache=true redis:latest", "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",
@redis.run.join(" ") 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 \ assert_equal \
"docker container start app-mysql", "docker container start app-mysql",
@mysql.start.join(" ") new_command(:mysql).start.join(" ")
end end
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker container stop app-mysql", "docker container stop app-mysql",
@mysql.stop.join(" ") new_command(:mysql).stop.join(" ")
end end
test "info" do test "info" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app-mysql", "docker ps --filter label=service=app-mysql",
@mysql.info.join(" ") 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").join(" ") 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").join(" ") 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
@@ -106,29 +118,34 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker logs app-mysql -t 2>&1", "docker logs app-mysql --timestamps 2>&1",
@mysql.logs.join(" ") new_command(:mysql).logs.join(" ")
assert_equal \ assert_equal \
"docker logs app-mysql --since 5m -n 100 -t 2>&1 | grep 'thing'", "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'",
@mysql.logs(since: "5m", lines: 100, grep: "thing").join(" ") new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.5 'docker logs app-mysql -t -n 10 -f 2>&1'", "ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
@mysql.follow_logs new_command(:mysql).follow_logs
end end
test "remove container" do test "remove container" do
assert_equal \ assert_equal \
"docker container prune -f --filter label=service=app-mysql", "docker container prune --force --filter label=service=app-mysql",
@mysql.remove_container.join(" ") new_command(:mysql).remove_container.join(" ")
end end
test "remove image" do test "remove image" do
assert_equal \ assert_equal \
"docker image prune -a -f --filter label=service=app-mysql", "docker image rm --force private.registry/mysql:8.0",
@mysql.remove_image.join(" ") 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,156 +13,262 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --log-opt max-size=10m --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.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.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-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 --log-opt max-size=10m --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.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.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-retry@docker\" dhh/app:999",
@app.run.join(" ") new_command.run.join(" ")
end end
test "run with custom healthcheck path" do test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" } @config[:healthcheck] = { "path" => "/healthz" }
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --log-opt max-size=10m --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=/healthz --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.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app.loadbalancer.healthcheck.path=\"/healthz\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-retry@docker\" dhh/app:999",
@app.run.join(" ") 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.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.retry.attempts=\"5\" --label traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app.middlewares=\"app-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

@@ -14,6 +14,22 @@ class CommandsAuditorTest < ActiveSupport::TestCase
new_command.record("app removed container").join(" ") new_command.record("app removed container").join(" ")
end 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 test "broadcast" do
assert_match \ assert_match \
/bin\/audit_broadcast '\[.*\] app removed container'/, /bin\/audit_broadcast '\[.*\] app removed container'/,
@@ -22,6 +38,6 @@ class CommandsAuditorTest < ActiveSupport::TestCase
private private
def new_command def new_command
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, version: "123")) Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"), role: @role)
end end
end end

View File

@@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command builder = new_builder_command
assert_equal "multiarch", builder.name assert_equal "multiarch", builder.name
assert_equal \ 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 .", "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(" ") builder.push.join(" ")
end end
@@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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 \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app . && docker push dhh/app:123", "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(" ") builder.push.join(" ")
end end
@@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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 \ 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 .", "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(" ") builder.push.join(" ")
end end
@@ -33,42 +33,56 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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 \ 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 .", "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(" ") 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 \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=app --build-arg a=1 --build-arg b=2", "-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(" ") 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 \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=app --secret id=token_a --secret id=token_b", "-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(" ") builder.target.build_options.join(" ")
end 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
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 \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app --build-arg a=1 --build-arg b=2 . && docker push dhh/app:123", "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(" ") 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 \ 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 .", "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(" ") 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 \ assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app --secret id=a --secret id=b . && docker push dhh/app:123", "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(" ") builder.push.join(" ")
end end

View File

@@ -10,7 +10,7 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run -d --name healthcheck-app-123 -p 3999:3000 --label service=healthcheck-app dhh/app:123", "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(" ") new_command.run.join(" ")
end end
@@ -18,13 +18,28 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
@config[:healthcheck] = { "port" => 3001 } @config[:healthcheck] = { "port" => 3001 }
assert_equal \ assert_equal \
"docker run -d --name healthcheck-app-123 -p 3999:3001 --label service=healthcheck-app dhh/app:123", "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(" ") new_command.run.join(" ")
end end
test "curl" do test "curl" do
assert_equal \ assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' http://localhost:3999/up", "curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/up",
new_command.curl.join(" ") new_command.curl.join(" ")
end end
@@ -32,24 +47,54 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
@config[:healthcheck] = { "path" => "/healthz" } @config[:healthcheck] = { "path" => "/healthz" }
assert_equal \ assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' http://localhost:3999/healthz", "curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/healthz",
new_command.curl.join(" ") new_command.curl.join(" ")
end end
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker container ls -a -f name=healthcheck-app -q | xargs docker stop", "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(" ") new_command.stop.join(" ")
end end
test "remove" do test "remove" do
assert_equal \ assert_equal \
"docker container ls -a -f name=healthcheck-app -q | xargs docker container rm", "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker container rm",
new_command.remove.join(" ") new_command.remove.join(" ")
end 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 private
def new_command def new_command
Mrsk::Commands::Healthcheck.new(Mrsk::Configuration.new(@config, version: "123")) Mrsk::Commands::Healthcheck.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"))
end end
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

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

@@ -4,13 +4,67 @@ class CommandsTraefikTest < ActiveSupport::TestCase
setup do setup do
@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: { "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 --log-opt max-size=10m -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\" traefik:v2.9.9 --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]["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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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\" traefik:v2.9.9 --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(" ") new_command.run.join(" ")
end end
@@ -28,55 +82,55 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "traefik info" do test "traefik info" do
assert_equal \ assert_equal \
"docker ps --filter name=traefik", "docker ps --filter name=^traefik$",
new_command.info.join(" ") new_command.info.join(" ")
end end
test "traefik logs" do test "traefik logs" do
assert_equal \ assert_equal \
"docker logs traefik -t 2>&1", "docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ") 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", "docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ") 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", "docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ") 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!'", "docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ") 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", "docker container prune --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_container.join(" ") 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", "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_image.join(" ") 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'", "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'",
new_command.follow_logs(host: @config[:servers].first) 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!\"'", "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!') new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end end

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,36 @@ 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_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) assert @config.accessory(:mysql).env_args[1].is_a?(SSHKit::Redaction)
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 +138,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.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.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app.middlewares=\"app-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,12 @@ 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.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.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app.middlewares=\"app-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 +95,9 @@ 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 assert_equal ["-e", "REDIS_PASSWORD=\"secret456\"", "-e", "DB_PASSWORD=\"secret&\\\"123\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
@@ -116,7 +116,7 @@ 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 assert_equal ["-e", "DB_PASSWORD=\"secret123\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
end end
@@ -133,7 +133,7 @@ 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 assert_equal ["-e", "REDIS_PASSWORD=\"secret456\"", "-e", "REDIS_URL=\"redis://a/b\"", "-e", "WEB_CONCURRENCY=\"4\""], @config_with_roles.role(:workers).env_args
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,7 +108,7 @@ 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
@@ -98,7 +117,7 @@ class ConfigurationTest < ActiveSupport::TestCase
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\"" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction) assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
@@ -109,7 +128,7 @@ 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
@@ -118,7 +137,7 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "secret" => [ "PASSWORD" ] } env: { "secret" => [ "PASSWORD" ] }
}) }) }) })
assert_equal [ "-e", "PASSWORD=secret123" ], config.env_args assert_equal [ "-e", "PASSWORD=\"secret123\"" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction) assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
@@ -134,6 +153,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 +209,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 +242,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"], :healthcheck=>{"path"=>"/up", "port"=>3000 }}, @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 }}, @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

39
test/utils_test.rb Normal file
View File

@@ -0,0 +1,39 @@
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" }, redacted: true).last
end
test "argumentize_env_with_secrets" do
ENV.expects(:fetch).with("FOO").returns("secret")
assert_equal [ "-e", "FOO=\"secret\"", "-e", "BAZ=\"qux\"" ], \
Mrsk::Utils.argumentize_env_with_secrets({ "secret" => [ "FOO" ], "clear" => { BAZ: "qux" } })
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 "redact" do
assert_kind_of SSHKit::Redaction, Mrsk::Utils.redact("secret")
assert_equal "secret", Mrsk::Utils.redact("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