Compare commits

...

163 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
Tobias Bühlmann
8b913068de Add destination to healthcheck containers names 2023-03-06 16:54:13 +01:00
Tobias Bühlmann
170562c7e7 Let App be aware of destination 2023-03-03 15:29:00 +01:00
61 changed files with 2287 additions and 653 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

View File

@@ -16,10 +16,6 @@ jobs:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
-
name: Extract Version Number
id: extract_version
run: echo "::set-output name=version::${{ github.ref | replace('refs/tags/', '') }}"
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
@@ -42,4 +38,4 @@ jobs:
push: true push: true
tags: | tags: |
ghcr.io/mrsked/mrsk:latest ghcr.io/mrsked/mrsk:latest
ghcr.io/mrsked/mrsk:${{ steps.extract_version.outputs.version }} 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

View File

@@ -1,6 +1,9 @@
# Use the official Ruby 3.2.0 Alpine image as the base image # Use the official Ruby 3.2.0 Alpine image as the base image
FROM ruby:3.2.0-alpine 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 # Set the working directory to /mrsk
WORKDIR /mrsk WORKDIR /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,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.9.0) mrsk (0.10.0)
activesupport (>= 7.0) activesupport (>= 7.0)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
dotenv (~> 2.8) dotenv (~> 2.8)
@@ -13,90 +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) 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) 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

171
README.md
View File

@@ -4,9 +4,23 @@ MRSK deploys web apps anywhere from bare metal to cloud VMs using Docker with ze
Watch the screencast: https://www.youtube.com/watch?v=LL1cV2FXZ5I 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
@@ -23,7 +37,7 @@ env:
- 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:
@@ -34,7 +48,7 @@ mrsk deploy
This will: This will:
1. Connect to the servers over SSH (using root by default, authenticated by your 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.
@@ -67,6 +81,16 @@ Docker Swarm is much simpler than Kubernetes, but it's still built on the same d
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. 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
### Using .env file to load required environment variables ### Using .env file to load required environment variables
@@ -99,9 +123,9 @@ If you need separate env variables for different destinations, you can set them
#### Bitwarden as a secret store #### 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. 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. You can store `SOME_SECRET` in a secure note in bitwarden vault.
``` ```
$ bw list items --search SOME_SECRET | jq $ bw list items --search SOME_SECRET | jq
@@ -140,7 +164,7 @@ SOME_SECRET=<%= `bw get notes 123123123-1232-4224-222f-234234234234 --session #{
<% else raise ArgumentError, "session_token token missing" end %> <% else raise ArgumentError, "session_token token missing" end %>
``` ```
Then everyone deploying the app can run `mrsk envify` and mrsk will generate `.env` 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
@@ -150,9 +174,9 @@ 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: username:
- DOCKER_REGISTRY_TOKEN - DOCKER_REGISTRY_TOKEN
password: password:
- DOCKER_REGISTRY_TOKEN - DOCKER_REGISTRY_TOKEN
``` ```
@@ -222,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:
@@ -256,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.
@@ -303,6 +333,29 @@ servers:
That'll start the job containers with `docker run ... --cap-add --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-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'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.
@@ -408,6 +461,46 @@ traefik:
host_port: 8080 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:
@@ -427,7 +520,7 @@ 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:
@@ -442,16 +535,44 @@ 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.
Accessory images must be public or tagged in your private registry.
### Using Cron ### Using Cron
You can use a specific container to run your Cron jobs: You can use a specific container to run your Cron jobs:
@@ -616,6 +737,30 @@ 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 beta software. Commands may still move around. But we're live in production at [37signals](https://37signals.com). This is beta software. Commands may still move around. But we're live in production at [37signals](https://37signals.com).

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,33 +1,38 @@
class Mrsk::Cli::Accessory < Mrsk::Cli::Base class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot new 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}" unless options[:skip_broadcast]
end end
end end
end end
desc "upload [NAME]", "Upload accessory files to host", hide: true 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
@@ -35,10 +40,12 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "directories [NAME]", "Create accessory directories on host", hide: true 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
@@ -46,38 +53,46 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "reboot [NAME]", "Reboot existing 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 container 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 existing accessory container 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 existing accessory container 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
@@ -87,7 +102,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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
@@ -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
@@ -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,25 +157,27 @@ 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 remove 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" 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) }
if options[:confirmed] || ask("This will remove all containers and images for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y" else
with_accessory(name) do 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"
stop(name) with_accessory(name) do
remove_container(name) stop(name)
remove_image(name) remove_container(name)
remove_service_directory(name) remove_image(name)
remove_service_directory(name)
end
end end
end end
end end
@@ -168,29 +185,35 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "remove_container [NAME]", "Remove accessory container from host", hide: true 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", hide: true 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", hide: true 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,32 +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} using a #{MRSK.config.readiness_delay}s readiness delay (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
cli = self cli = self
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
execute *MRSK.auditor.record("Booted app version #{version}"), verbosity: :debug
begin on(MRSK.hosts) do |host|
old_version = capture_with_info(*MRSK.app.current_running_version).strip roles = MRSK.roles_on(host)
execute *MRSK.app.run(role: role.name)
sleep MRSK.config.readiness_delay
execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
rescue SSHKit::Command::Failed => e roles.each do |role|
if e.message =~ /already in use/ execute *MRSK.auditor(role: role).record("Booted app version #{version}"), verbosity: :debug
error "Rebooting container with same version #{version} already deployed on #{host} (may cause gap in zero-downtime promise!)"
execute *MRSK.auditor.record("Rebooted app version #{version}"), verbosity: :debug
execute *MRSK.app.stop(version: version) begin
execute *MRSK.app.remove_container(version: version) if capture_with_info(*MRSK.app(role: role).container_id_for_version(version)).present?
execute *MRSK.app.run(role: role.name) tmp_version = "#{version}_#{SecureRandom.hex(8)}"
else info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
raise 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
@@ -36,24 +35,42 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "start", "Start existing app container on servers" 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 container 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
# FIXME: Drop in favor of just containers? # FIXME: Drop in favor of just containers?
desc "details", "Show details about app 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 (use --help to show options)" desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
@@ -65,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
@@ -81,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
@@ -121,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
@@ -140,32 +169,48 @@ 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
stop with_lock do
remove_containers stop
remove_images remove_containers
remove_images
end
end end
desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true 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", hide: true 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", hide: true 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
@@ -189,20 +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 }
if version == "<none>"
raise "Most recent image available was not tagged with a version (returned <none>)"
else
version.presence
end
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"
@@ -25,7 +27,7 @@ module Mrsk::Cli
def initialize(*) def initialize(*)
super super
load_envs load_envs
initialize_commander(options) initialize_commander(options_with_subcommand_class_options)
end end
private private
@@ -37,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
@@ -55,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
@@ -70,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,26 +1,30 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Build app and push app image to registry then pull image on servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
push with_lock do
pull push
pull
end
end end
desc "push", "Build 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
@@ -28,25 +32,29 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "pull", "Pull app image from 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.clean, raise_on_non_zero_exit: false execute *MRSK.auditor.record("Pulled image with version #{MRSK.config.version}"), verbosity: :debug
execute *MRSK.builder.pull execute *MRSK.builder.clean, raise_on_non_zero_exit: false
execute *MRSK.builder.pull
end
end end
end end
desc "create", "Create a 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
@@ -54,9 +62,11 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "remove", "Remove 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

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,77 +1,104 @@
class Mrsk::Cli::Main < Mrsk::Cli::Base class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy 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 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 curl and Docker are 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"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all", [], invoke_options
end
audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end end
audit_broadcast "Deployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
end end
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login" desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy def redeploy
runtime = print_runtime do with_lock do
say "Build and push app image...", :magenta invoke_options = deploy_options
invoke "mrsk:cli:build:deliver"
say "Ensure app can pass healthcheck...", :magenta runtime = print_runtime do
invoke "mrsk:cli:healthcheck:perform" 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
invoke "mrsk:cli:app:boot" 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
audit_broadcast "Redeployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
end end
desc "rollback [VERSION]", "Rollback app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version) def rollback(version)
MRSK.version = version with_lock do
MRSK.config.version = version
if container_name_available?(MRSK.config.service_with_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 say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
cli = self cli = self
old_version = nil
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence
execute *MRSK.app.start execute *MRSK.app.start
sleep MRSK.config.readiness_delay if old_version
sleep MRSK.config.readiness_delay
execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false 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
audit_broadcast "Rolled back app to version #{version}" unless options[:skip_broadcast]
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end end
end end
@@ -119,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
@@ -142,11 +171,13 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "remove", "Remove Traefik, app, accessories, 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" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove def remove
if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y" with_lock do
invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed) 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:app:remove", [], options.without(:confirmed) invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:accessory:remove", [ "all" ], options invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed) invoke "mrsk:cli:accessory:remove", [ "all" ], options
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed)
end
end end
end end
@@ -179,10 +210,21 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "traefik", "Manage 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
containers with_lock do
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 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,14 +1,16 @@
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure curl and Docker are installed on servers" desc "bootstrap", "Ensure curl and Docker are installed on servers"
def bootstrap def bootstrap
on(MRSK.hosts + MRSK.accessory_hosts) do with_lock do
dependencies_to_install = Array.new.tap do |dependencies| on(MRSK.hosts + MRSK.accessory_hosts) do
dependencies << "curl" unless execute "which curl", raise_on_non_zero_exit: false dependencies_to_install = Array.new.tap do |dependencies|
dependencies << "docker.io" unless execute "which docker", raise_on_non_zero_exit: false dependencies << "curl" unless execute "which curl", raise_on_non_zero_exit: false
end dependencies << "docker.io" unless execute "which docker", raise_on_non_zero_exit: false
end
if dependencies_to_install.any? if dependencies_to_install.any?
execute "apt-get update -y && apt-get install #{dependencies_to_install.join(" ")} -y)" execute "apt-get update -y && apt-get install #{dependencies_to_install.join(" ")} -y"
end
end end
end end
end end

View File

@@ -13,6 +13,8 @@ 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
# Always use an access token rather than real password when possible.
password: password:
- MRSK_REGISTRY_PASSWORD - MRSK_REGISTRY_PASSWORD

View File

@@ -1,36 +1,46 @@
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
stop with_lock do
remove_container stop
boot remove_container
boot
end
end end
desc "start", "Start existing Traefik container 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 existing Traefik container 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 existing Traefik container on servers" desc "restart", "Restart existing Traefik container on servers"
def restart def restart
stop with_lock do
start stop
start
end
end end
desc "details", "Show details about Traefik container from servers" desc "details", "Show details about Traefik container from servers"
@@ -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
stop with_lock do
remove_container stop
remove_image remove_container
remove_image
end
end end
desc "remove_container", "Remove Traefik container from servers", hide: true desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container def remove_container
on(MRSK.traefik_hosts) do with_lock do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug on(MRSK.traefik_hosts) do
execute *MRSK.traefik.remove_container execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end end
end end
desc "remove_container", "Remove Traefik image from servers", hide: true 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&.first || config.primary_web_host specific_hosts&.first || config.primary_web_host
end end
def roles
(specific_roles || config.roles).select do |role|
((specific_hosts || config.all_hosts) & role.hosts).any?
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,16 +67,16 @@ 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 end
def accessory(name) def accessory(name)
Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end end
def auditor def auditor(role: nil)
@auditor ||= Mrsk::Commands::Auditor.new(config) Mrsk::Commands::Auditor.new(config, role: role)
end end
def builder def builder
@@ -77,6 +99,9 @@ class Mrsk::Commander
@traefik ||= Mrsk::Commands::Traefik.new(config) @traefik ||= Mrsk::Commands::Traefik.new(config)
end 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)
@@ -12,12 +13,14 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
"--name", service_name, "--name", service_name,
"--detach", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", *config.logging_args,
"--publish", 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
@@ -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
@@ -100,7 +103,7 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def remove_image def remove_image
docker :image, :prune, "--all", "--force", *service_filter docker :image, :rm, "--force", image
end end
private private

View File

@@ -1,13 +1,21 @@
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,
"--detach", "--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, *role.option_args,
@@ -16,17 +24,17 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def start def start
docker :start, service_with_version docker :start, container_name
end end
def stop(version: nil) def stop(version: nil)
pipe \ pipe \
version ? container_id_for_version(version) : current_container_id, version ? container_id_for_version(version) : current_container_id,
xargs(docker(:stop)) 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
@@ -51,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
@@ -75,32 +83,23 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def current_container_id def current_container_id
docker :ps, "--quiet", *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, "--all", *service_filter docker :container, :ls, "--all", *filter_args
end end
def list_container_names def list_container_names
@@ -109,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, "--force", *service_filter docker :container, :prune, "--force", *filter_args
end end
def list_images def list_images
@@ -122,24 +125,23 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def remove_images def remove_images
docker :image, :prune, "--all", "--force", *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}" end
else
config.service_with_version def filter_args
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
def container_id_for_version(version)
container_id_for(container_name: service_with_version(version))
end
def service_filter
[ "--filter", "label=service=#{config.service}" ]
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, "--all", "--filter", "name=#{container_name}", "--quiet" 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

@@ -7,6 +7,7 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
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

View File

@@ -10,7 +10,8 @@ class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def push def push
combine \ combine \
docker(:build, *build_options, build_context), 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

@@ -9,8 +9,10 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
"--name", container_name_with_version, "--name", container_name_with_version,
"--publish", "#{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
@@ -33,15 +35,15 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
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 end
def container_id def container_id
container_id_for(container_name: container_name) 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

@@ -1,16 +1,18 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
delegate :optionize, to: Mrsk::Utils delegate :optionize, to: Mrsk::Utils
IMAGE = "traefik:v2.9.9"
CONTAINER_PORT = 80 CONTAINER_PORT = 80
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
"--detach", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--publish", port, "--publish", port,
"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"traefik", *config.logging_args,
*docker_options_args,
IMAGE,
"--providers.docker", "--providers.docker",
"--log.level=DEBUG", "--log.level=DEBUG",
*cmd_option_args *cmd_option_args
@@ -25,7 +27,7 @@ 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)
@@ -49,20 +51,24 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
def port def port
"#{host_port}:#{CONTAINER_PORT}" "#{host_port}:#{CONTAINER_PORT}"
end end
private private
def docker_options_args
optionize(config.traefik["options"] || {})
end
def cmd_option_args def cmd_option_args
if args = config.raw_config.dig(:traefik, "args") if args = config.traefik["args"]
optionize args optionize args, with: "="
else else
[] []
end end
end end
def host_port def host_port
config.raw_config.dig(:traefik, "host_port") || CONTAINER_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?
@@ -159,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
@@ -179,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
@@ -193,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

@@ -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
@@ -55,12 +59,16 @@ 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
@@ -69,8 +77,9 @@ class Mrsk::Configuration::Role
"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" => "5", "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

@@ -24,8 +24,19 @@ module Mrsk::Utils
end end
# Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option. # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
def optionize(args) def optionize(args, with: nil)
args.collect { |(key, value)| [ "--#{key}", value == true ? nil : escape_shell_value(value) ] }.flatten.compact 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 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
@@ -37,4 +48,9 @@ module Mrsk::Utils
def escape_shell_value(value) def escape_shell_value(value)
value.to_s.dump.gsub(/`/, '\\\\`') value.to_s.dump.gsub(/`/, '\\\\`')
end 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.9.0" VERSION = "0.10.0"
end end

View File

@@ -19,4 +19,8 @@ Gem::Specification.new do |spec|
spec.add_dependency "zeitwerk", "~> 2.5" spec.add_dependency "zeitwerk", "~> 2.5"
spec.add_dependency "ed25519", "~> 1.2" spec.add_dependency "ed25519", "~> 1.2"
spec.add_dependency "bcrypt_pbkdf", "~> 1.0" 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,42 +1,138 @@
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 --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", 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 test "remove with confirmation" do
run_command("remove", "mysql", "-y").tap do |output| Mrsk::Cli::Accessory.any_instance.expects(:stop).with("mysql")
assert_match /docker container stop app-mysql/, output Mrsk::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output Mrsk::Cli::Accessory.any_instance.expects(:remove_image).with("mysql")
assert_match /rm -rf app-mysql/, output Mrsk::Cli::Accessory.any_instance.expects(:remove_service_directory).with("mysql")
end
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 end
private private

View File

@@ -3,35 +3,30 @@ require_relative "cli_test_case"
class CliAppTest < CliTestCase class CliAppTest < CliTestCase
test "boot" do test "boot" do
# Stub current version fetch # Stub current version fetch
SSHKit::Backend::Abstract.any_instance.stubs(:capture) SSHKit::Backend::Abstract.any_instance.stubs(:capture).returns("123") # old version
.returns("999") # new version
.then
.returns("123") # old version
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match /docker run --detach --restart unless-stopped/, output assert_match "docker run --detach --restart unless-stopped", output
assert_match /docker container ls --all --filter name=app-123 --quiet | xargs docker stop/, output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
end end
test "boot will reboot if same version is already running" do test "boot will rename if same version is already running" do
run_command("details") # Preheat MRSK const run_command("details") # Preheat MRSK const
# Prevent expected failures from outputting to terminal SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
Thread.report_on_exception = false .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet")
.returns("12345678") # running version
MRSK.app.stubs(:run) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.raises(SSHKit::Command::Failed.new("already in use")) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1")
.then .returns("123") # old version
.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 999 already deployed/, output # Can't start what's already running assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker container ls --all --filter name=app-999 --quiet | xargs docker container rm/, output # Stop old running assert_match /docker rename .* .*/, output
assert_match /docker container ls --all --filter name=app-999 --quiet | xargs docker container rm/, output # Remove old container assert_match "docker run --detach --restart unless-stopped", output
assert_match /docker run/, output # Start new container 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
@@ -39,51 +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 --quiet --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
end end
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop")}/, output
assert_match /docker container prune --force --filter label=service=app/, output assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /docker image prune --all --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 --all --filter name=app-1234567 --quiet \| 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

View File

@@ -1,10 +1,70 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliBuildTest < CliTestCase 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 test "pull" do
run_command("pull").tap do |output| run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull 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
end end
@@ -12,4 +72,11 @@ class CliBuildTest < CliTestCase
def run_command(*command) def run_command(*command)
stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end 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 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,9 +1,82 @@
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
@@ -17,16 +90,141 @@ class CliMainTest < CliTestCase
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 /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 test "remove with confirmation" do
run_command("remove", "-y").tap do |output| run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output|
assert_match /docker container stop traefik/, 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 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 image prune --all --force --filter label=org.opencontainers.image.title=Traefik/, output
@@ -37,20 +235,25 @@ class CliMainTest < CliTestCase
assert_match /docker container stop app-mysql/, output assert_match /docker container stop app-mysql/, output
assert_match /docker container prune --force --filter label=service=app-mysql/, output assert_match /docker container prune --force --filter label=service=app-mysql/, output
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output assert_match /docker image rm --force mysql/, output
assert_match /rm -rf app-mysql/, output assert_match /rm -rf app-mysql/, output
assert_match /docker container stop app-redis/, output assert_match /docker container stop app-redis/, output
assert_match /docker container prune --force --filter label=service=app-redis/, output assert_match /docker container prune --force --filter label=service=app-redis/, output
assert_match /docker image prune --all --force --filter label=service=app-redis/, output assert_match /docker image rm --force redis/, output
assert_match /rm -rf app-redis/, output assert_match /rm -rf app-redis/, output
assert_match /docker logout/, output assert_match /docker logout/, output
end end
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

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
@@ -47,4 +50,9 @@ class CommanderTest < ActiveSupport::TestCase
@mrsk.specific_roles = "web" @mrsk.specific_roles = "web"
assert_equal "1.1.1.1", @mrsk.primary_host assert_equal "1.1.1.1", @mrsk.primary_host
end 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 --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\" 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 --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", "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
@@ -107,28 +119,33 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker logs app-mysql --timestamps 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 --tail 100 --timestamps 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 --timestamps --tail 10 --follow 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 --force --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 --all --force --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,165 +13,262 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --detach --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=\"5\" --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 --detach --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=\"5\" --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 --detach --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=\"5\" --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 end
test "run with custom options" do 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 } } } @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config).tap { |c| c.version = "999" } 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 \ assert_equal \
"docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --label service=\"app\" --label role=\"jobs\" --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", "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",
@app.run(role: :jobs).join(" ") 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 --quiet --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 --quiet --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 --quiet --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 --quiet --filter label=service=app | xargs docker logs --tail 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 --quiet --filter label=service=app | xargs docker logs --since 5m --tail 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 --quiet --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 --quiet --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 --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 2>&1", new_command.follow_logs(host: "app-1")
@app.follow_logs(host: "app-1")
assert_equal \ assert_match \
"docker ps --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 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 --quiet --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 --all --filter name=app-999 --quiet", "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

@@ -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\" --file Dockerfile . && 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
@@ -68,7 +68,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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\" --file Dockerfile . && 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
@@ -82,7 +82,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
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\" --file Dockerfile . && 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 --detach --name healthcheck-app-123 --publish 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,7 +18,22 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
@config[:healthcheck] = { "port" => 3001 } @config[:healthcheck] = { "port" => 3001 }
assert_equal \ assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 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
@@ -38,18 +53,48 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker container ls --all --filter name=healthcheck-app --quiet | 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 --all --filter name=healthcheck-app --quiet | 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

@@ -4,18 +4,51 @@ 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 --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /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(" ") new_command.run.join(" ")
@config[:traefik]["host_port"] = "8080" @config[:traefik]["host_port"] = "8080"
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 8080:80 --volume /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 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(" ") new_command.run.join(" ")
end end
@@ -23,7 +56,15 @@ class CommandsTraefikTest < ActiveSupport::TestCase
@config.delete(:traefik) @config.delete(:traefik)
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG", "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
@@ -41,7 +82,7 @@ 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

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,10 +90,21 @@ 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
@@ -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

@@ -42,7 +42,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
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=\"5\"", "--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
@@ -66,7 +66,7 @@ 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=\"5\"", "--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

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
@@ -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,9 +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 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