Compare commits

..

216 Commits

Author SHA1 Message Date
Donal McBreen
61b7dc90f2 Bump version for 0.15.0 2023-07-24 13:43:50 +01:00
David Heinemeier Hansson
f6442513ae Merge pull request #357 from igor-alexandrov/documentation-update
Updated README with info for Rails <7 usage
2023-07-24 14:39:48 +02:00
Donal McBreen
5e8df58e6b Merge pull request #393 from basecamp/rolling-traefik-restarts
Support a --rolling option for traefik reboots
2023-07-19 16:43:59 +01:00
Lewis Buckley
9d5a6d1321 Document the rolling option for traefik reboots 2023-07-19 15:03:15 +01:00
Lewis Buckley
ecfd258093 Document the rolling reboot option 2023-07-19 14:58:46 +01:00
Lewis Buckley
313f89a108 Merge branch 'main' into rolling-traefik-restarts
* main:
  Removed not needed MRSK.traefik.run command in Traefil reboot
  Updated README with locking directory name
  Include service name to lock details
  Configurable SSH log levels
  Add registry container output to debug
  Minor tweaks to hooks section in readme
  Update README.md
  Updated README.md to make setup examples consistent
  Login to the registry proactively before stoping Accessory and Traefik
2023-07-19 14:46:16 +01:00
Lewis Buckley
9ab448e186 Support a --rolling option for traefik reboots 2023-07-19 14:39:27 +01:00
Donal McBreen
e1433f3895 Merge pull request #349 from igor-alexandrov/login-to-registry-proactively
Login to the registry proactively before stoping Accessory and Traefik
2023-07-19 13:36:00 +01:00
Igor Alexandrov
a29e188c90 Removed not needed MRSK.traefik.run command in Traefil reboot 2023-07-19 15:08:42 +04:00
Donal McBreen
95e3915991 Merge pull request #386 from mrsked/ssh-log-levels
Configurable SSH log levels
2023-07-17 14:09:21 +01:00
Donal McBreen
30d342183d Merge pull request #387 from igor-alexandrov/lock-with-service-name
Include service name to lock details
2023-07-17 14:07:22 +01:00
Igor Alexandrov
83f5f3f053 Updated README with locking directory name 2023-07-17 10:39:50 +04:00
Igor Alexandrov
e6ca270537 Include service name to lock details 2023-07-15 21:50:39 +04:00
Donal McBreen
cd88c49c42 Configurable SSH log levels
Allow ssh log_level to be set to debug connection issues.
2023-07-14 16:08:47 +01:00
Donal McBreen
d03195ce1c Merge pull request #385 from mrsked/integration-test-registry-debug
Add registry container output to debug
2023-07-14 13:50:31 +01:00
Donal McBreen
da1c049829 Add registry container output to debug 2023-07-14 13:41:30 +01:00
Donal McBreen
4095e1853d Merge pull request #356 from helgeblod/fix-readme-username-typo
Update README.md to make setup examples consistent
2023-07-12 13:24:51 +01:00
Donal McBreen
dbc9989730 Merge pull request #364 from nickhammond/patch-2
Update README.md
2023-07-12 13:22:53 +01:00
Donal McBreen
e493369453 Merge pull request #379 from nickhammond/patch-3
Minor tweaks to hooks section in readme
2023-07-12 11:29:36 +01:00
Nick Hammond
e760cfa457 Minor tweaks to hooks section in readme 2023-07-08 10:59:55 -06:00
Nick Hammond
f8d651af0d Update README.md
Add a note about utilizing biometrics with the envify example.
2023-06-28 10:03:27 -06:00
Igor Alexandrov
08172be375 Updated README with info for Rails <7 usage 2023-06-26 16:05:24 +04:00
Jonas Helgemo
a3cc2317e2 Updated README.md to make setup examples consistent
- SSH and apt examples should use same username
2023-06-26 11:57:23 +02:00
Igor Alexandrov
2746a48e88 Login to the registry proactively before stoping Accessory and Traefik 2023-06-22 15:13:47 +04:00
David Heinemeier Hansson
9a501867b4 Bump version for 0.14.0 2023-06-20 17:02:42 +02:00
David Heinemeier Hansson
c5397ff51e Merge pull request #342 from mrsked/only-require-secrets-when-mutating
Only require secrets when mutating
2023-06-20 16:50:14 +02:00
Donal McBreen
4950f61a87 Only require secrets when mutating
Rename `with_lock` to more generic `mutating` and move the env_args
check to that point. This allows read-only actions to be run without
requiring secrets.
2023-06-20 15:39:51 +01:00
David Heinemeier Hansson
08d8790851 Merge pull request #337 from igor-alexandrov/feature/cache
Support for Docker multistage build cache
2023-06-20 11:38:46 +02:00
Igor Alexandrov
02256ac8fe More code style improvements 2023-06-19 18:22:07 +04:00
Igor Alexandrov
dadd8225da Various code style improvements 2023-06-18 23:39:44 +04:00
Igor Alexandrov
aa28ee0f3e Inroduce Native::Cached builder 2023-06-18 22:45:04 +04:00
David Heinemeier Hansson
2007ab475e Merge pull request #327 from bestfriendfinance/fix-run-over-ssh-with-proxy-command
Add support for proxy_command to run_over_ssh
2023-06-18 17:18:50 +02:00
Igor Alexandrov
4df3389d09 Added support for multistage build cache 2023-06-18 19:02:10 +04:00
Matt Robinson
21b13bf8d3 Add support for proxy_command to run_over_ssh 2023-06-16 08:22:10 -03:00
David Heinemeier Hansson
6e6f696717 Merge pull request #335 from mrsked/specify-min-version
Add a minimum version setting
2023-06-16 10:02:40 +02:00
Donal McBreen
98c12a254e Add a minimum version setting
Allow a minimum MRSK version to be specified in the config.
2023-06-15 14:53:03 +01:00
Donal McBreen
f0301d2007 Merge pull request #328 from igor-alexandrov/319-override-default-traefik-args
Added ability to override default Traefik command line arguments
2023-06-15 14:49:56 +01:00
Igor Alexandrov
d3f5e9efe8 Updated Traefik CLI test 2023-06-15 17:11:20 +04:00
Igor Alexandrov
d9b3fac17a Added ability to override default Traefik command line arguments 2023-06-15 15:41:20 +04:00
Donal McBreen
cd5c41ddbe Merge pull request #334 from basecamp/fix-ssh-symlink
Fix ssh symlink
2023-06-15 12:14:30 +01:00
Donal McBreen
a14c6141e5 Dump container logs on failure 2023-06-15 12:05:50 +01:00
Donal McBreen
95d6ee5031 Remove /root/.ssh before symlinking
Ensure the symlinks are created correctly whether or not /root/.ssh
already exists.
2023-06-15 12:02:56 +01:00
David Heinemeier Hansson
80a4ca4f8a Merge pull request #331 from basecamp/fix-sample-pre-deploy-hook
Fix up the sample pre-deploy hook
2023-06-12 14:09:29 +02:00
Donal McBreen
12ca865e71 Fix up the sample pre-deploy hook
- Fix the shebang
- Extract a GithubStatusChecks class
2023-06-12 12:47:51 +01:00
David Heinemeier Hansson
66b4a0ea40 Bump version for 0.13.2 2023-06-01 12:54:06 +02:00
David Heinemeier Hansson
04b39ea798 Fix spelling 2023-05-31 18:15:21 +02:00
David Heinemeier Hansson
ae55a7b5d8 Merge pull request #325 from mrsked/revert-324-fix-filename-typo
Revert "Fix typo in fixture filename"
2023-05-31 18:14:51 +02:00
David Heinemeier Hansson
601cfbd95e Revert "Fix typo in fixture filename" 2023-05-31 18:14:43 +02:00
David Heinemeier Hansson
9fdc85c2e6 Merge pull request #324 from basecamp/fix-filename-typo
Fix typo in fixture filename
2023-05-31 18:13:11 +02:00
Donal McBreen
222eda6085 Fix typo in fixture filename
Following on from https://github.com/mrsked/mrsk/pull/320, fix the
fixture filename as well
2023-05-31 17:11:13 +01:00
David Heinemeier Hansson
504a09ef1d Merge pull request #318 from basecamp/pre-deploy-hook
Add a pre-deploy hook
2023-05-31 17:59:46 +02:00
David Heinemeier Hansson
5a25f073f7 Merge pull request #320 from jsoref/spelling
Spelling
2023-05-31 17:59:18 +02:00
David Heinemeier Hansson
c8f521c0e8 Merge pull request #323 from basecamp/prefix-docker-host-with-real-host
Prefix container hostname with the underlying one
2023-05-31 17:58:55 +02:00
Donal McBreen
28d6a131a9 Prefix container hostname with the underlying one
To make it easier to identity where a docker container is running,
prefix its hostname with the underlying one from the host.

Docker chooses a 12 character random hex string by default, so we'll
keep that as the suffix.
2023-05-31 16:22:25 +01:00
David Heinemeier Hansson
3a9075b8ba Merge pull request #321 from basecamp/more-robust-image-pruning 2023-05-31 13:02:48 +02:00
Donal McBreen
079d9538bb Improve image pruning robustness
If you different images with the same git SHA, on the second deploy the
tag is moved and the first image becomes untagged. It may however still
be attached to an existing container.

To handle this:
1. Initially prune dangling images - this will remove any untagged
images that are not attached to an existing image
2. Then filter out the untagged images when deleting tagged images - any
that remain will be attached to a container.

The second issue is that `docker container ls -a --format '{{.Image}}`
will sometimes return the image id rather than a tag. This means that
the image doesn't get filtered out when we grep to remove the active
images.

To fix that we'll grep against both the image id and repo:tag.
2023-05-31 10:17:52 +01:00
Josh Soref
8e94c21729 spelling: with
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
b536fcfa43 spelling: percentage
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
85005be07f spelling: message
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
fc00392d68 spelling: installed
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
fe9affa349 spelling: healthchecks
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
3ecb3a4bfc spelling: guidelines
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
787812cdc2 spelling: every time
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Josh Soref
91fb85d6b5 spelling: etc.
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2023-05-29 20:46:34 -04:00
Donal McBreen
db0bf6bb16 Add a pre-deploy hook
Useful for checking the status of CI before deploying. Doing this at
this point in the deployment maximises the parallelisation of building
and running CI.
2023-05-29 16:06:41 +01:00
David Heinemeier Hansson
de2de19434 Merge pull request #315 from basecamp/prune-unused-images
Prune unused images correctly
2023-05-29 11:42:49 +02:00
David Heinemeier Hansson
f9fbebaa72 Merge pull request #316 from f440/fix-typo
Fix typo
2023-05-29 11:42:26 +02:00
Donal McBreen
1e300f3798 Wait longer for app to come up 2023-05-29 08:31:19 +01:00
f440
0373f6c4de Fix typo 2023-05-27 16:27:19 +09:00
Donal McBreen
9037088f99 Increase nginx timeouts in load balancer 2023-05-25 17:31:20 +01:00
Donal McBreen
ff7a1e6726 Prune unused images correctly
dangling=true doesn't prune any images, as we are not creating dangling
images.

Using --all should remove unused images, but it considers the Git SHA
tag on the latest image to be unused (presumably because there are two
tags, the SHA and latest and the running container is only considered to
be using "latest"). As a result it deletes the tag, which means that we
can't rollback to that SHA later.

Its a bit more complicated to only remove images that are not referenced
by any containers.

First we find the tags we want to keep from the containers (running and
stopped).

Then we append the latest tag to that list.

Then we get a full list of image tags and remove those tags from that
list (using `grep -v -w`).

Finally we pass the tags to `docker rmi`. That either deletes the tag if
there are other references to the image or both the tag and the image if
it is the only one.
2023-05-25 17:16:46 +01:00
David Heinemeier Hansson
602aa43496 Bump version for 0.13.1 2023-05-25 14:04:29 +02:00
David Heinemeier Hansson
e35334e5fe Merge pull request #313 from basecamp/stop-restarting-containers
Stop containers with restarting status
2023-05-25 14:04:09 +02:00
Donal McBreen
cedb8d900f Stop containers with restarting status
When stopping the old container we need to also look for ones with a
restarting status.
2023-05-25 12:10:26 +01:00
David Heinemeier Hansson
8f0b7829ce Bump version for 0.13.0 2023-05-25 12:05:04 +02:00
David Heinemeier Hansson
57e4f08c4c Merge pull request #308 from tannakartikey/hooks_small_fix
Hooks sample files typo fix
2023-05-25 09:02:25 +02:00
David Heinemeier Hansson
a8bfe90fbe Merge pull request #312 from shafy/docs_docker_setup
docs: change intro command to mrsk setup
2023-05-25 09:01:04 +02:00
David Heinemeier Hansson
f114dd71f6 Merge pull request #311 from basecamp/pre-connect-hook
Add a pre-connect hook
2023-05-25 08:58:19 +02:00
Can Olcer
d1b5b9cf7a docs: change intro command to mrsk setup 2023-05-24 20:32:41 +02:00
Donal McBreen
66f9ce0e90 Add a pre-connect hook
This can be used for hooks that should run before connecting to remote
hosts. An example use case is pre-warming DNS.
2023-05-24 14:39:30 +01:00
Kartikey Tanna
956ab3560b Hooks typo fix 2023-05-23 22:12:49 +05:30
David Heinemeier Hansson
483b893018 Merge pull request #291 from basecamp/hooks
MRSK Hooks
2023-05-23 17:07:04 +02:00
Donal McBreen
19f0f40adf Add skip_hooks option 2023-05-23 15:56:47 +01:00
Donal McBreen
f9cb87e55a Fixup rebase issues 2023-05-23 14:10:38 +01:00
Donal McBreen
cc2b321d93 Combine post-deploy and post-rollback 2023-05-23 13:57:24 +01:00
Donal McBreen
004f1b04e6 Remove the skip_broadcast option 2023-05-23 13:57:00 +01:00
Donal McBreen
3b695ae127 Add service_version and add running hook message 2023-05-23 13:56:19 +01:00
Donal McBreen
258887a451 Set sample hook permissions and preserve when copying 2023-05-23 13:56:19 +01:00
Donal McBreen
9fd184dc32 Add post-deploy and post-rollback hooks
These replace the custom audit_broadcast_cmd code. An additional env
variable MRSK_RUNTIME is passed to them.

The audit broadcast after booting an accessory has been removed.
2023-05-23 13:56:16 +01:00
Donal McBreen
38023fe538 Remove post push hook 2023-05-23 13:55:05 +01:00
Donal McBreen
0bc1fbfb74 Set max-concurrent-downloads to 1 to prevent timeouts 2023-05-23 13:55:05 +01:00
David Heinemeier Hansson
5ab630cb03 Style 2023-05-23 13:55:04 +01:00
Donal McBreen
910f14e9c0 Add configuration for hooks_path 2023-05-23 13:55:04 +01:00
Donal McBreen
f3ec9f19c8 Add debug for failed version checks 2023-05-23 13:55:04 +01:00
Donal McBreen
58c1096a90 MRSK hooks
Adds hooks to MRSK. Currently just two hooks, pre-build and post-push.

We could break the build and push into two separate commands if we
found the need for post-build and/or pre-push hooks.

Hooks are stored in `.mrsk/hooks`. Running `mrsk init` will now create
that folder and add sample hook scripts.

Hooks returning non-zero exit codes will abort the current command.

Further potential work here:
- We could replace the audit broadcast command with a
post-deploy/post-rollback hook or similar
- Maybe provide pre-command/post-command hooks that run after every
mrsk invocation
- Also look for hooks in `~/.mrsk/hooks`
2023-05-23 13:55:04 +01:00
Donal McBreen
340ed94fa9 Make verify_local_dependencies private
We don't need to what it returns, it raises if there is a problem.

Move it out of the run_locally block to make it easier to add hooks.
2023-05-23 13:55:04 +01:00
David Heinemeier Hansson
4e9c39f26d Merge pull request #271 from basecamp/app-boot-for-rollback
Call app:boot to rollback
2023-05-23 13:17:30 +02:00
David Heinemeier Hansson
d08aacadac Merge pull request #287 from Novtopro/traefik-inject-environment-variables
Allow to inject environment variables to traefik
2023-05-22 10:10:34 +02:00
David Heinemeier Hansson
702490d10f Merge pull request #305 from johnmcdowall/update_readme_for_aws_ecr
Update the README with info on AWS ECR
2023-05-22 09:58:33 +02:00
David Heinemeier Hansson
13079dd2a3 Merge pull request #299 from basecamp/report-host-with-error
Report the host an error occurred on
2023-05-22 09:57:22 +02:00
John McDowall
7daee9a0df Update the README on the use of shelling out to the aws cli command to obtain the token for ECR automatically 2023-05-20 13:46:05 -07:00
Donal McBreen
f7c5840473 Report the host an error occurred on
The cause message doesn't include the host the error occurred on.

Before:

```
$ mrsk deploy
Acquiring the deploy lock...
  Finished all in 0.1 seconds
  ERROR (SocketError): getaddrinfo: nodename nor servname provided, or not known
```

After:

```
$ mrsk deploy -d staging
Acquiring the deploy lock...
  Finished all in 0.1 seconds
  ERROR (SocketError): Exception while executing on host server-123: getaddrinfo: nodename nor servname provided, or not known
```
2023-05-17 08:51:01 +01:00
David Heinemeier Hansson
a7d869ad40 Merge pull request #298 from basecamp/more-integration-tests 2023-05-17 09:39:00 +02:00
Donal McBreen
7cd25fd163 Add more integration tests
Add tests for main, app, accessory, traefik and lock commands.
Other commands are generally covered by the main tests.

Also adds some changes to speed up the integration specs:
- Use a persistent volume for the registry so we can push images to to
reuse between runs (also gets around docker hub rate limits)
- Use persistent volume for mrsk gem install, to avoid re-installing
between tests
- Shorter stop wait time
- Shorter connection timeouts on the load balancer

Takes just over 2 minutes to run all tests locally on an M1 Mac
after docker caches are primed.
2023-05-16 10:35:35 +01:00
Donal McBreen
ee25f200d7 Call app:boot to rollback
The code in Mrsk::Cli::Main#rollback was very similar to
Mrsk::Cli::App#boot.

Modify Mrsk::Cli::App#boot so it can handle rollbacks by:
1. Only renaming running containers
2. Trying first to start then run the new container
2023-05-16 08:59:07 +01:00
David Heinemeier Hansson
059388cb02 Merge pull request #292 from basecamp/unique-uncommited-changes-version
Highlight uncommitted changes in version
2023-05-15 14:05:31 +02:00
Donal McBreen
a5ef1f254f Highlight uncommitted changes in version
If there are uncommitted changes in the app repository when building,
then append `_uncommitted_<random>` to it to distinguish the image
from one built from a clean checkout.

Also change the version used when renaming a container on redeploy to
distinguish and explain the version suffixes.
2023-05-12 11:08:48 +01:00
David Heinemeier Hansson
15e8ac0ced Merge pull request #290 from acidtib/check-interval
add healthcheck interval option
2023-05-11 14:13:13 +02:00
acidtib
9a31c20321 add healthcheck interval option 2023-05-10 20:27:21 -06:00
River He
44b83151e3 Allow to inject environment variables to traefik 2023-05-10 03:18:26 +00:00
David Heinemeier Hansson
0defcbb640 Merge pull request #283 from basecamp/better-lock-messages
Better lock messages
2023-05-09 16:06:50 +02:00
Donal McBreen
5d33fb6c33 Better lock messages
- Debug verbosity commands
- Show lock status when we fail to acquire it
- Include lock acquire/release in runtime
2023-05-09 14:17:58 +01:00
David Heinemeier Hansson
e9d838ec46 Update README.md 2023-05-09 14:32:02 +02:00
David Heinemeier Hansson
ee319fee1c Merge pull request #277 from xiaohui-zhangxh/bugfix/readme
Fix readme bug on traefik volumes option
2023-05-09 12:37:38 +02:00
xiaohui
5646f6cc64 fix readme bug on traefik volumes option 2023-05-07 23:58:38 +08:00
David Heinemeier Hansson
31aaa82991 Merge pull request #272 from olimart/patch-1
Fix typo mesasge --> message
2023-05-07 10:58:11 +02:00
Olivier
5ea552be40 Fix typo in message 2023-05-05 10:43:21 -04:00
David Heinemeier Hansson
625be70e4d Bump version for 0.12.1 2023-05-05 14:33:25 +02:00
David Heinemeier Hansson
aafaee7ac8 Merge pull request #223 from basecamp/customizable-audit-broadcast
Allow customizing audit broadcast with env
2023-05-05 14:30:04 +02:00
David Heinemeier Hansson
97a190300d Merge pull request #270 from basecamp/fix-aggressive-prune-breaking-rollback
Fix aggressive prune breaking rollback
2023-05-05 14:28:22 +02:00
Donal McBreen
326711a3e0 Fix aggressive prune breaking rollback
In the image prune command --all overrides --dangling=true. This removes
the image git sha image tag for the latest image which prevented
us from rolling back to it.

I've updated the integration test to now test deploy, redeploy and
rollback.
2023-05-05 12:13:14 +01:00
Kevin McConnell
82be521e66 Merge branch 'main' into customizable-audit-broadcast
* main:
  Fix staging label bug
  Fix typo
  Capture container health log when unhealthy
  Bump version for 0.12.0
2023-05-05 11:40:29 +01:00
David Heinemeier Hansson
21110080d5 Merge pull request #267 from danthegoodman1/patch-1
Fix staging label bug in README
2023-05-05 11:25:22 +02:00
David Heinemeier Hansson
ef107c41b6 Merge pull request #265 from Jberczel/improve-healthcheck-logging
Improve healthcheck logging
2023-05-05 11:24:55 +02:00
Dan Goodman
1bf4b6b76f Fix staging label bug
I think this is the correct fix based on the `service-role-destination` format, but seeing as it wasn't changed I assumed it was incorrect.
2023-05-04 17:47:17 -04:00
Jeremy Daer
36a3b13bf4 Fix SSHKit #command override args mangling 2023-05-04 08:58:18 -07:00
Jberczel
01483140f5 Fix typo 2023-05-03 15:03:05 -04:00
Jberczel
0e19ead37c Capture container health log when unhealthy 2023-05-03 15:03:05 -04:00
Jeremy Daer
048aecf352 Audit details (#1)
Audit details

* Audit logs and broadcasts accept `details` whose values are included as log tags and MRSK_* env vars passed to the broadcast command
* Commands may return execution options to the CLI in their args list
* Introduce `mrsk broadcast` helper for sending audit broadcasts
* Report UTC time, not local time, in audit logs. Standardize on ISO 8601 format
2023-05-02 11:42:05 -07:00
David Heinemeier Hansson
38c85e8021 Bump version for 0.12.0 2023-05-02 17:23:10 +02:00
David Heinemeier Hansson
88a7413b3e Merge branch 'main' into pr/223
* main:
  Don't run actions twice on PRs
  Further distinguish dependency verification
  Naming
  Reveal configured dockerfile path
  Style
  Distinguish from server dependencies
  Distinguish from local dependency verification
  Improve clarity and intent
  Style
  Style
  Style
  Add local dependencies check
  Bootstrap: use multi-platform installer
2023-05-02 14:44:16 +02:00
David Heinemeier Hansson
9cc73fed9a Merge branch 'main' into pr/223
* main:
  Simplify domain language to just "boot" and unscoped config keys
  Retain a fixed number of containers when pruning
  Don't assume rolling back in message
  Check all hosts before rolling back
  Ensure Traefik service name is consistent
  Extend traefik delay by 1 second
  Include traefik access logs
  Check if we are still getting a 404
  Also dump load balancer logs
  Dump traefik logs when app not booted
  Fix missing for apt-get
  Report on container health after failure
  Fix the integration test healthcheck
  Allow percentage-based rolling deployments
  Move `group_limit` & `group_wait` under `boot`
  Limit rolling deployment to boot operation
  Allow performing boot & start operations in groups
2023-05-02 14:43:17 +02:00
David Heinemeier Hansson
787ef96639 Don't run actions twice on PRs 2023-05-02 14:41:18 +02:00
David Heinemeier Hansson
1e8edc25e2 Merge pull request #205 from basecamp/docker-readiness
Bootstrap: multi-OS Docker install
2023-05-02 14:35:26 +02:00
David Heinemeier Hansson
b7877c59b4 Merge branch 'main' into docker-readiness 2023-05-02 14:30:35 +02:00
David Heinemeier Hansson
35b5b317af Merge branch 'main' into pr/205
* main:
  Simplify domain language to just "boot" and unscoped config keys
  Retain a fixed number of containers when pruning
  Don't assume rolling back in message
  Check all hosts before rolling back
  Ensure Traefik service name is consistent
  Extend traefik delay by 1 second
  Include traefik access logs
  Check if we are still getting a 404
  Also dump load balancer logs
  Dump traefik logs when app not booted
  Fix missing for apt-get
  Report on container health after failure
  Fix the integration test healthcheck
  Allow percentage-based rolling deployments
  Move `group_limit` & `group_wait` under `boot`
  Limit rolling deployment to boot operation
  Allow performing boot & start operations in groups
2023-05-02 14:29:06 +02:00
David Heinemeier Hansson
4c448f7eb1 Merge pull request #256 from Jberczel/check-local-dependencies
Add local dependencies check
2023-05-02 14:13:23 +02:00
David Heinemeier Hansson
263a24afe3 Further distinguish dependency verification 2023-05-02 14:09:10 +02:00
David Heinemeier Hansson
a2d99e48bf Naming 2023-05-02 14:08:29 +02:00
David Heinemeier Hansson
a22e27dbf8 Reveal configured dockerfile path 2023-05-02 14:07:47 +02:00
David Heinemeier Hansson
bb74a74dc4 Style 2023-05-02 14:07:30 +02:00
David Heinemeier Hansson
c611a1616a Distinguish from server dependencies 2023-05-02 14:06:06 +02:00
David Heinemeier Hansson
98e7b995d5 Distinguish from local dependency verification 2023-05-02 14:04:37 +02:00
David Heinemeier Hansson
ae2effb80c Improve clarity and intent 2023-05-02 14:04:23 +02:00
David Heinemeier Hansson
f719540e0c Style 2023-05-02 13:35:05 +02:00
David Heinemeier Hansson
cbda851436 Style 2023-05-02 13:34:56 +02:00
David Heinemeier Hansson
8854bb63a1 Merge pull request #254 from basecamp/retain-last-5-containers
Retain a fixed number of containers when pruning
2023-05-02 13:16:49 +02:00
David Heinemeier Hansson
35ea9f3c81 Merge pull request #255 from basecamp/check-all-hosts-for-rollback-container
Check all hosts before rolling back
2023-05-02 13:16:03 +02:00
David Heinemeier Hansson
18312f5191 Merge pull request #253 from basecamp/ensure-consistent-service-name
Ensure Traefik service name is consistent
2023-05-02 13:15:36 +02:00
David Heinemeier Hansson
71bc9bcf54 Merge pull request #222 from basecamp/deploy-groups
Allow booting containers in groups for rolling restarts
2023-05-02 13:14:32 +02:00
David Heinemeier Hansson
c83b74dcb7 Simplify domain language to just "boot" and unscoped config keys 2023-05-02 13:11:31 +02:00
Donal McBreen
971a91da15 Retain a fixed number of containers when pruning
Time based container and image retention can have variable space
requirements depending on how often we deploy.

- Only prune stopped containers, retaining the 5 newest
- Then prune dangling images so we only keep images for the retained
containers.
2023-05-02 10:15:08 +01:00
Donal McBreen
86d6f8d674 Don't assume rolling back in message 2023-05-02 10:14:50 +01:00
Donal McBreen
7fe24d5048 Check all hosts before rolling back
Hosts could end up out of sync with each other if prune commands are run
manually or when new hosts are added.

Before rolling back confirm that the required container is available on
all hosts and roles.
2023-05-02 10:14:50 +01:00
Kevin McConnell
a72f95f44d Ensure Traefik service name is consistent
If we don't specify any service properties when labelling containers,
the generated service will be named according to the container. However,
we change the container name on every deployment (as it is versioned),
which means that the auto-generated service name will be different in
each container.

That is a problem for two reasons:

- Multiple containers share a common router while a deployment is
  happening. At this point, the router configuration will be different
  between the containers; Traefik flags this as an error, and stops
  routing to the containers until it's resolved.
- We allow custom labels to be set in an app's config. In order to
  define custom configuration on the service, we'll need to know what
  it will be called.

Changed to force the service name by setting one of its properties.
2023-05-02 09:43:04 +01:00
David Heinemeier Hansson
dc3be30b16 Style 2023-05-02 10:29:49 +02:00
David Heinemeier Hansson
54881a0298 Merge pull request #250 from basecamp/integration-test-healthcheck-wget
Integration test healthcheck wget
2023-05-02 10:27:49 +02:00
David Heinemeier Hansson
19527b4f65 Merge branch 'main' into customizable-audit-broadcast 2023-05-02 10:25:25 +02:00
Jberczel
bfb70b2118 Add local dependencies check
Add checks for:

* Docker installed locally
* Docker buildx plugin installed locally
* Dockerfile exists

If checks fail, it will halt deployment and provide more specific error messages.

Also adds a cli subcommand:
`mrsk build dependencies`

Fixes: #109 and #237
2023-05-01 16:32:41 -04:00
Jeremy Daer
e85bd5ff63 Bootstrap: use multi-platform installer
* Limit auto-install to root users; otherwise, give manual install guidance
* Support non-Debian/Ubuntu with the multi-OS get.docker.com installer
2023-05-01 13:26:00 -07:00
Donal McBreen
d0f66db33c Extend traefik delay by 1 second 2023-05-01 18:58:46 +01:00
Donal McBreen
650f9b1fbf Include traefik access logs 2023-05-01 18:55:10 +01:00
Donal McBreen
1170e2311e Check if we are still getting a 404 2023-05-01 18:32:07 +01:00
Donal McBreen
94f87edded Also dump load balancer logs 2023-05-01 18:27:08 +01:00
Donal McBreen
548a1019c1 Dump traefik logs when app not booted 2023-05-01 18:21:22 +01:00
Donal McBreen
ca2e2bac2e Fix missing for apt-get 2023-05-01 12:50:45 +01:00
Donal McBreen
494a1ae089 Report on container health after failure 2023-05-01 12:13:12 +01:00
Donal McBreen
a77428143f Fix the integration test healthcheck
The alpine nginx container doesn't contain curl, so let's override the
healthcheck command to use wget.
2023-05-01 12:11:24 +01:00
David Heinemeier Hansson
4fa6a6c06d Merge pull request #219 from basecamp/docker-health-checks 2023-04-28 11:43:33 +02:00
David Heinemeier Hansson
2ad0dc0703 Merge pull request #241 from Jberczel/fix-traefik-subcommand-typo 2023-04-28 11:38:46 +02:00
David Heinemeier Hansson
df067e4893 Merge pull request #244 from basecamp/get-lock-status-without-invoke 2023-04-25 18:57:05 +02:00
Donal McBreen
cd668066ff Get lock status by executing directly
Getting the lock status with invoke passes through any options from the
original command which will raise an exception if they are not also
valid for the lock status command.

Fixes https://github.com/mrsked/mrsk/issues/239
2023-04-25 16:57:02 +01:00
David Heinemeier Hansson
1a7d123746 Merge pull request #245 from basecamp/integration-test-wait-for-healthy
Wait for healthy containers in integration test
2023-04-25 16:54:22 +02:00
Donal McBreen
52ca5b846a Wait for healthy containers in integration test
Rather than waiting 5 seconds and hoping for the best after we boot
docker compose, add docker healthchecks and wait for all the containers
to be healthy.
2023-04-25 15:41:25 +01:00
Jberczel
126e0bbd06 Fix traefik remove_image desc typo 2023-04-24 17:40:28 -04:00
David Heinemeier Hansson
9ec3895dab Merge pull request #216 from dmrty/add-ssh-client-to-dockerised-mrsk 2023-04-15 08:29:49 +02:00
David Heinemeier Hansson
a6245a6bc9 Merge pull request #221 from iamFIREcracker/patch-1 2023-04-15 08:29:03 +02:00
David Heinemeier Hansson
0d80709e2d Merge pull request #224 from basecamp/integration-tests 2023-04-15 08:25:47 +02:00
Kevin McConnell
aceabb3824 Update README with env name change 2023-04-14 16:13:59 +01:00
Kevin McConnell
99fe31d4b4 Rename MRSK_EVENT -> MRSK_MESSAGE
It's a better name, and frees up `MRSK_EVENT` to be used later.
2023-04-14 16:11:42 +01:00
Donal McBreen
bcf8a927f5 Run a mrsk deploy integration test
Adds a simple integration test to ensure that `mrsk deploy` works.

Everything required is spun up with docker compose:
- shared: a container that contains an ssh key and a self signed cert to
be shared between the images
- deployer: the image we will deploy from
- registry: a docker registry
- two vm images to deploy into
- load_balancer: an nginx load balancer to use between our images

The other images are in privileged mode so that we can run
docker-in-docker. We need to run docker inside the images - mapping in
the docker socket doesn't work because both VMs would share the host
daemon.

The docker registry requires a self signed cert as you cannot use basic
auth over HTTP except on localhost. It runs on port 4443 rather than 443
because docker refused to accept that "registry" is a docker host and
tries to push images to docker.io/registry. "registry:4443" works fine.

The shared container contains the ssh keys for the deployer and vms, and
the self signed cert for the registry. When the shared container boots,
it copies them into a shared volume.

The other deployer and vm images are built with soft links from the
shared volume to the require locations. Their boot scripts wait for the
files to be copied in before continuing.

The root mrsk folder is mapped into the deployer container. On boot it
builds the gem and installs it.

Right now there's just a single test. We confirm that the load balancer
is returning a 502, run `mrsk deploy` and then confirm it returns 200.
2023-04-14 15:49:43 +01:00
Kevin McConnell
f055766918 Allow percentage-based rolling deployments 2023-04-14 12:46:14 +01:00
Kevin McConnell
a8726be20e Move group_limit & group_wait under boot
Also make formatting the group strategy the responsibility of the
commander.
2023-04-14 11:31:51 +01:00
Kevin McConnell
100b72e4b4 Limit rolling deployment to boot operation 2023-04-14 10:41:07 +01:00
Kevin McConnell
828e56912e Allow customizing audit broadcast with env
When invoking the audit broadcast command, provide a few environment
variables so that people can customize the format of the message if they
want.

We currently provide `MRSK_PERFORMER`, `MRSK_ROLE`, `MRSK_DESTINATION` and
`MRSK_EVENT`.

Also adds the destination to the default message, which we continue to
send as the first argument as before.
2023-04-13 17:54:25 +01:00
Kevin McConnell
df202d6ef4 Move health checks into Docker
Replaces our current host-based HTTP healthchecks with Docker
healthchecks, and adds a new `healthcheck.cmd` config option that can be
used to define a custom health check command. Also removes Traefik's
healthchecks, since they are no longer necessary.

When deploying a container that has a healthcheck defined, we wait for
it to report a healthy status before stopping the old container that it
replaces. Containers that don't have a healthcheck defined continue to
wait for `MRSK.config.readiness_delay`.

There are some pros and cons to using Docker healthchecks rather than
checking from the host. The main advantages are:

- Supports non-HTTP checks, and app-specific check scripts provided by a
  container.
- When booting a container, allows MRSK to wait for a container to be
  healthy before shutting down the old container it replaces. This
  should be safer than relying on a timeout.
- Containers with healthchecks won't be active in Traefik until they
  reach a healthy state, which prevents any traffic from being routed to
  them before they are ready.

The main _disadvantage_ is that containers are now required to provide
some way to check their health. Our default check assumes that `curl` is
available in the container which, while common, won't always be the
case.
2023-04-13 16:08:43 +01:00
Kevin McConnell
f530009a6e Allow performing boot & start operations in groups
Adds top-level configuration options for `group_limit` and `group_wait`.
When a `group_limit` is present, we'll perform app boot & start
operations on no more than `group_limit` hosts at a time, optionally
sleeping for `group_wait` seconds after each batch.

We currently only do this batching on boot & start operations (including
when they are part of a deployment). Other commands, like `app stop` or
`app details` still work on all hosts in parallel.
2023-04-13 15:58:27 +01:00
Matteo Landi
4b36df5dab Configure git to trust /workdir
Resolves: #220
2023-04-13 15:13:13 +02:00
Gilles Demarty
79d46ceb16 Add OpenSSH Client to the alpine server 2023-04-12 19:20:09 +02:00
David Heinemeier Hansson
bc8875e020 Merge pull request #183 from basecamp/cleanup-excessive-containers-running
Clear stale containers
2023-04-12 15:58:59 +02:00
David Heinemeier Hansson
d4a72da9d8 Merge pull request #213 from ncreuschling/fix-spelling-of-label
fix spelling of label
2023-04-12 15:58:46 +02:00
David Heinemeier Hansson
04a04c05e0 Merge branch 'main' into fix-spelling-of-label 2023-04-12 15:58:41 +02:00
David Heinemeier Hansson
cff8b058af Merge pull request #214 from tannakartikey/traefik_lables_readme_example_fix
Traefik label example typo fix
2023-04-12 15:58:08 +02:00
David Heinemeier Hansson
b6f7d94ac3 Merge pull request #144 from monorkin/shell-escape-dollar-signs
Shell escape dollar signs
2023-04-12 15:57:37 +02:00
Stanko K.R
3ab16c8994 Shell escape dollar signs
But allow for shell expansion using curly braces e.g. ${PWD}
2023-04-12 15:55:54 +02:00
Kartikey Tanna
b6743e5e1c Traefik label example typo fix 2023-04-12 19:21:20 +05:30
Jacopo
9ddb181f50 Merge branch 'main' into cleanup-excessive-containers-running
* main:
  Pull the primary host from the role
  Minimise holding the deploy lock
2023-04-12 15:19:19 +02:00
Nicolai Reuschling
fbe1458478 fix spelling of label 2023-04-12 14:56:39 +02:00
David Heinemeier Hansson
2f1393cd92 Merge pull request #212 from basecamp/role-primary-hosts
Pull the primary host from the role
2023-04-12 14:09:38 +02:00
David Heinemeier Hansson
76673c0c1b Merge pull request #211 from basecamp/minimise-lock-retention
Minimise holding the deploy lock
2023-04-12 14:08:05 +02:00
Donal McBreen
fb62f2e6e1 Pull the primary host from the role
So commands like this run on a host with the specified role:
```
mrsk app exec -r=console -i "/bin/bash`
mrsk app logs -f -r=workers
```
2023-04-12 13:03:02 +01:00
Donal McBreen
051556674f Minimise holding the deploy lock
If we get an error we'll only hold the deploy lock if it occurs while
trying to switch the running containers.

We'll also move tagging the latest image from when the image is pulled
to just before the container switch. This ensures that earlier errors
don't leave the hosts with an updated latest tag while still running the
older version.
2023-04-12 12:09:56 +01:00
Jacopo
3cbf4aea46 Make method private method and use :send 2023-04-12 11:53:49 +02:00
Jacopo
5ed431b807 Merge branch 'main' into cleanup-excessive-containers-running
* main: (24 commits)
  Bump version for 0.11.0
  Labels can be added to Traefik
  Make rollbacks role-aware
  fix typo role to roles
  Explained the latest modifications of Traefik container labels
  Remove .idea folder
  Updated README.md with new healthcheck.max_attempts option
  Fix test case: console output message was not updated to display the current/total attempts
  Require net-ssh ~> 7.0 for SHA-2 support
  Improved deploy lock acquisition
  Excess CR
  Style
  Simpler
  Make it explicit, focus on Ubuntu
  More explicit
  Not that --bundle is a Rails 7+ option
  Update README.md
  Update README.md
  Improved: configurable max_attempts for healthcheck
  Traefik service name to be derived from role and destination
  ...
2023-04-12 11:52:47 +02:00
Jacopo
72b70e3e9e More compact 2023-04-11 16:22:47 +02:00
Jacopo
e8697327fa Use no_commands block 2023-04-11 16:20:16 +02:00
Jacopo
0bfd4ca780 Use cli = self approach 2023-04-11 16:04:46 +02:00
Jacopo
12e3a562c4 Extract helper 2023-04-11 15:26:55 +02:00
Jacopo
c3393c8213 Remove dot 2023-04-11 11:03:11 +02:00
Jacopo
03d933d10b Add Role to the message 2023-04-11 10:59:25 +02:00
Jacopo
579b4cd9aa Simplify
By using and ad-hoc command to detect and stop stale containers.
By default stale containers are only detected.
2023-04-11 10:22:03 +02:00
Jacopo
f9436d5673 Style 2023-04-11 08:53:33 +02:00
Jacopo
8ae5331d97 Boot stop all the old containers 2023-04-11 08:53:33 +02:00
Jacopo
4d47fbdf41 Merge stop and stop_stale_containers 2023-04-11 08:53:33 +02:00
Jacopo
e980f1164e Avoid using GNU-only Perl Regepx Grep 2023-04-11 08:53:33 +02:00
Jacopo
e2f6db5cae Clear stale containers
By stopping all the older containers with matching /#{service}-#{role}-#{dest}-.*/ running on the same host.
2023-04-11 08:53:33 +02:00
102 changed files with 2894 additions and 600 deletions

View File

@@ -1,5 +1,9 @@
name: CI name: CI
on: [push, pull_request] on:
push:
branches:
- main
pull_request:
jobs: jobs:
tests: tests:
strategy: strategy:

View File

@@ -27,7 +27,7 @@ Please keep the following guidelines in mind when opening a pull request:
- Add tests for your changes, if possible. - Add tests for your changes, if possible.
- Ensure that your changes don't break existing functionality. - Ensure that your changes don't break existing functionality.
#### Commit message guidline #### Commit message guidelines
A good commit message should describe what changed and why. A good commit message should describe what changed and why.

View File

@@ -14,14 +14,14 @@ COPY Gemfile Gemfile.lock mrsk.gemspec ./
COPY lib/mrsk/version.rb /mrsk/lib/mrsk/version.rb COPY lib/mrsk/version.rb /mrsk/lib/mrsk/version.rb
# Install system dependencies # Install system dependencies
RUN apk add --no-cache --update build-base git docker openrc \ RUN apk add --no-cache --update build-base git docker openrc openssh-client-default \
&& rc-update add docker boot \ && rc-update add docker boot \
&& gem install bundler --version=2.4.3 \ && gem install bundler --version=2.4.3 \
&& bundle install && bundle install
# Copy the rest of our application code into the container. # Copy the rest of our application code into the container.
# We do this after bundle install, to avoid having to run bundle # We do this after bundle install, to avoid having to run bundle
# everytime we do small fixes in the source code. # every time we do small fixes in the source code.
COPY . . COPY . .
# Install the gem locally from the project folder # Install the gem locally from the project folder
@@ -31,6 +31,10 @@ RUN gem build mrsk.gemspec && \
# Set the working directory to /workdir # Set the working directory to /workdir
WORKDIR /workdir WORKDIR /workdir
# Tell git it's safe to access /workdir/.git even if
# the directory is owned by a different user
RUN git config --global --add safe.directory /workdir
# Set the entrypoint to run the installed binary in /workdir # Set the entrypoint to run the installed binary in /workdir
# Example: docker run -it -v "$PWD:/workdir" mrsk init # Example: docker run -it -v "$PWD:/workdir" mrsk init
ENTRYPOINT ["mrsk"] ENTRYPOINT ["mrsk"]

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.11.0) mrsk (0.15.0)
activesupport (>= 7.0) activesupport (>= 7.0)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
dotenv (~> 2.8) dotenv (~> 2.8)

267
README.md
View File

@@ -44,30 +44,48 @@ Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSW
Now you're ready to deploy to the servers: Now you're ready to deploy to the servers:
``` ```
mrsk deploy mrsk setup
``` ```
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): root access is needed via ssh for this. 2. Install Docker and curl on any server that might be missing it (using apt-get): root access is needed via ssh for this.
3. Log into the registry both locally and remotely 3. Log into the registry both locally and remotely
4. Build the image using the standard Dockerfile in the root of the application. 4. Build the image using the standard Dockerfile in the root of the application.
5. Push the image to the registry. 5. Push the image to the registry.
6. Pull the image from the registry onto the servers. 6. Pull the image from the registry onto the servers.
7. Ensure Traefik is running and accepting traffic on port 80. 7. Ensure Traefik is running and accepting traffic on port 80.
8. Ensure your app responds with `200 OK` to `GET /up`. 8. Ensure your app responds with `200 OK` to `GET /up` (you must have curl installed inside your app image!).
9. Start a new container with the version of the app that matches the current git version hash. 9. Start a new container with the version of the app that matches the current git version hash.
10. Stop the old container running the previous version of the app. 10. Stop the old container running the previous version of the app.
11. Prune unused images and stopped containers to ensure servers don't fill up. 11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. For subsequent deploys, or if your servers already have Docker and curl installed, you can just run `mrsk deploy`.
### Rails <7 usage
MRSK is not needed to be in your application Gemfile to be used. However, if you want to guarantee specific MRSK version in your CI/CD workflows, you can create a separate Gemfile for MRSK, for example, `gemfile/mrsk.gemfile`:
```ruby
source 'https://rubygems.org'
gem 'mrsk', '~> 0.14'
```
Bundle with `BUNDLE_GEMFILE=gemfiles/mrsk.gemfile bundle`.
After this MRSK can be used for deployment:
```sh
BUNDLE_GEMFILE=gemfiles/mrsk.gemfile bundle exec mrsk deploy
```
## Vision ## Vision
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on your own hardware, or even just have a clear migration path to do so in the future, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole! In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on your own hardware, or even just have a clear migration path to do so in the future, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated bare metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes. MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc., or it's your own colocated bare metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This approach gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there are a lot of compelling options available. This approach gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there are a lot of compelling options available.
@@ -123,6 +141,8 @@ This template can safely be checked into git. Then everyone deploying the app ca
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`. If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`.
Note: If you utilize biometrics with 1Password you can remove the `session_token` related parts in the example and just call `op read op://Vault/Docker Hub/password -n`.
#### 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.
@@ -184,6 +204,19 @@ registry:
A reference to secret `DOCKER_REGISTRY_TOKEN` will look for `ENV["DOCKER_REGISTRY_TOKEN"]` on the machine running MRSK. A reference to secret `DOCKER_REGISTRY_TOKEN` will look for `ENV["DOCKER_REGISTRY_TOKEN"]` on the machine running MRSK.
#### Using AWS ECR as the container registry
AWS ECR's access token is only valid for 12hrs. In order to not have to manually regenerate the token every time, you can use ERB in the `deploy.yml` file to shell out to the `aws` cli command, and obtain the token:
```yaml
registry:
server: <your aws account id>.dkr.ecr.<your aws region id>.amazonaws.com
username: AWS
password: <%= %x(aws ecr get-login-password) %>
```
You will need to have the `aws` CLI installed locally for this to work.
### Using a different SSH user than root ### Using a different SSH user than root
The default SSH user is root, but you can change it using `ssh/user`: The default SSH user is root, but you can change it using `ssh/user`:
@@ -193,13 +226,13 @@ ssh:
user: app user: app
``` ```
If you are using non-root user, you need to bootstrap your servers manually, before using them with MRSK. On Ubuntu, you'd do: If you are using non-root user (`app` as above example), you need to bootstrap your servers manually, before using them with MRSK. On Ubuntu, you'd do:
```bash ```bash
sudo apt update sudo apt update
sudo apt upgrade -y sudo apt upgrade -y
sudo apt install -y docker.io curl git sudo apt install -y docker.io curl git
sudo usermod -a -G docker ubuntu sudo usermod -a -G docker app
``` ```
### Using a proxy SSH host ### Using a proxy SSH host
@@ -225,6 +258,15 @@ ssh:
proxy_command: aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region=us-east-1 ## ssh via aws ssm proxy_command: aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region=us-east-1 ## ssh via aws ssm
``` ```
### Configuring the SSH log level
```yaml
ssh:
log_level: debug
```
Valid levels are `debug`, `info`, `warn`, `error` and `fatal` (default).
### Using env variables ### Using env variables
You can inject env variables into the app containers using `env`: You can inject env variables into the app containers using `env`:
@@ -308,7 +350,7 @@ You can specialize the default Traefik rules by setting labels on the containers
labels: labels:
traefik.http.routers.hey-web.rule: Host(`app.hey.com`) traefik.http.routers.hey-web.rule: Host(`app.hey.com`)
``` ```
Traefik rules are in the "service-role-destination" format. The default role will be `web` if no rule is specified. If the destination is not specified, it is not included. To give an example, the above rule would become "traefik.http.routers.hey-web.rule" if it was for the "staging" destination. Traefik rules are in the "service-role-destination" format. The default role will be `web` if no rule is specified. If the destination is not specified, it is not included. To give an example, the above rule would become "traefik.http.routers.hey-web-staging.rule" if it was for the "staging" destination.
Note: The 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!
@@ -331,6 +373,21 @@ servers:
my-label: "50" my-label: "50"
``` ```
### Using shell expansion
You can use shell expansion to interpolate values from the host machine into labels and env variables with the `${}` syntax.
Anything within the curly braces will be executed on the host machine and the result will be interpolated into the label or env variable.
```yaml
labels:
host-machine: "${cat /etc/hostname}"
env:
HOST_DEPLOYMENT_DIR: "${PWD}"
```
Note: Any other occurrence of `$` will be escaped to prevent unwanted shell expansion!
### Using container options ### Using container options
You can specialize the options used to start containers using the `options` definitions: You can specialize the options used to start containers using the `options` definitions:
@@ -352,6 +409,16 @@ 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 ...`.
### Setting a minimum version
You can set the minimum MRSK version with:
```yaml
minimum_version: 0.13.3
```
Note: versions <= 0.13.2 will ignore this setting.
### Configuring logging ### Configuring logging
You can configure the logging driver and options passed to Docker using `logging`: You can configure the logging driver and options passed to Docker using `logging`:
@@ -435,6 +502,37 @@ builder:
context: ".." context: ".."
``` ```
### Using multistage builder cache
Docker multistage build cache can singlehandedly speed up your builds by a lot. Currently MRSK only supports using the GHA cache or the Registry cache:
```yaml
# Using GHA cache
builder:
cache:
type: gha
# Using Registry cache
builder:
cache:
type: registry
# Using Registry cache with different cache image
builder:
cache:
type: registry
# default image name is <image>-build-cache
image: application-cache-image
# Using Registry cache with additinonal cache-to options
builder:
cache:
type: registry
options: mode=max,image-manifest=true,oci-mediatypes=true
```
For further insights into build cache optimization, check out documentation on Docker's official website: https://docs.docker.com/build/cache/.
### Using build secrets for new images ### Using build secrets for new images
Some images need a secret passed in during build time, like a GITHUB_TOKEN, to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration: Some images need a secret passed in during build time, like a GITHUB_TOKEN, to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration:
@@ -507,25 +605,25 @@ traefik:
options: options:
publish: publish:
- 8080:8080 - 8080:8080
volumes: volume:
- /tmp/example.json:/tmp/example.json - /tmp/example.json:/tmp/example.json
memory: 512m memory: 512m
``` ```
This starts the Traefik container with `--volume /tmp/example.json:/tmp/example.json --publish 8080:8080 --memory 512m` arguments to `docker run`. This starts the Traefik container with `--volume /tmp/example.json:/tmp/example.json --publish 8080:8080 --memory 512m` arguments to `docker run`.
### Traefik container lables ### Traefik container labels
Add labels to Traefik Docker container. Add labels to Traefik Docker container.
```yaml ```yaml
traefik: traefik:
lables: labels:
- traefik.enable: true traefik.enable: true
- traefik.http.routers.dashboard.rule: Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`)) traefik.http.routers.dashboard.rule: Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
- traefik.http.routers.dashboard.service: api@internal traefik.http.routers.dashboard.service: api@internal
- traefik.http.routers.dashboard.middlewares: auth traefik.http.routers.dashboard.middlewares: auth
- traefik.http.middlewares.auth.basicauth.users: test:$2y$05$H2o72tMaO.TwY1wNQUV1K.fhjRgLHRDWohFvUZOJHBEtUXNKrqUKi # test:password traefik.http.middlewares.auth.basicauth.users: test:$2y$05$H2o72tMaO.TwY1wNQUV1K.fhjRgLHRDWohFvUZOJHBEtUXNKrqUKi # test:password
``` ```
This labels Traefik container with `--label traefik.http.routers.dashboard.middlewares=\"auth\"` and so on. This labels Traefik container with `--label traefik.http.routers.dashboard.middlewares=\"auth\"` and so on.
@@ -553,6 +651,16 @@ traefik:
entrypoints.otherentrypoint.address: ':9000' entrypoints.otherentrypoint.address: ':9000'
``` ```
### Rebooting Traefik
If you make changes to Traefik args or labels, you'll need to reboot with:
`mrsk traefik reboot`
In production, reboot the Traefik containers one by one with a slower but safer approach, using a rolling reboot:
`mrsk traefik reboot --rolling`
### 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:
@@ -640,42 +748,45 @@ servers:
This assumes the Cron settings are stored in `config/crontab`. This assumes the Cron settings are stored in `config/crontab`.
### Using audit broadcasts ### Healthcheck
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument: MRSK uses Docker healthchecks to check the health of your application during deployment. Traefik uses this same healthcheck status to determine when a container is ready to receive traffic.
```yaml The healthcheck defaults to testing the HTTP response to the path `/up` on port 3000, up to 7 times. You can tailor this behaviour with the `healthcheck` setting:
audit_broadcast_cmd:
bin/audit_broadcast
```
The broadcast command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like follows to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
### Custom healthcheck
MRSK defaults to checking the health of your application again `/up` on port 3000 up to 7 times. You can tailor the behaviour with the `healthcheck` setting:
```yaml ```yaml
healthcheck: healthcheck:
path: /healthz path: /healthz
port: 4000 port: 4000
max_attempts: 7 max_attempts: 7
interval: 20s
``` ```
This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000. This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
The healthcheck also allows for an optional `max_attempts` setting, which will attempt the healthcheck up to the specified number of times before failing the deploy. This is useful for applications that take a while to start up. The default is 7. You can also specify a custom healthcheck command, which is useful for non-HTTP services:
```yaml
healthcheck:
cmd: /bin/check_health
```
The top-level healthcheck configuration applies to all services that use
Traefik, by default. You can also specialize the configuration at the role
level:
```yaml
servers:
job:
hosts: ...
cmd: bin/jobs
healthcheck:
cmd: bin/check
```
The healthcheck allows for an optional `max_attempts` setting, which will attempt the healthcheck up to the specified number of times before failing the deploy. This is useful for applications that take a while to start up. The default is 7.
Note: The HTTP health checks assume that the `curl` command is available inside the container. If that's not the case, use the healthcheck's `cmd` option to specify an alternative check that the container supports.
## Commands ## Commands
@@ -794,7 +905,7 @@ If you wish to remove the entire application, including Traefik, containers, ima
## Locking ## 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. Commands that are unsafe to run concurrently will take a deploy lock while they run. The lock is the `mrsk_lock-<service>` directory on the primary server.
You can check the lock status with: You can check the lock status with:
@@ -809,13 +920,85 @@ Message: Automatic deploy lock
You can also manually acquire and release the lock You can also manually acquire and release the lock
``` ```
mrsk lock acquire -m "Doing maintanence" mrsk lock acquire -m "Doing maintenance"
``` ```
``` ```
mrsk lock release mrsk lock release
``` ```
## Rolling deployments
When deploying to large numbers of hosts, you might prefer not to restart your services on every host at the same time.
MRSK's default is to boot new containers on all hosts in parallel. But you can control this by configuring `boot/limit` and `boot/wait` as options:
```yaml
service: myservice
boot:
limit: 10 # Can also specify as a percentage of total hosts, such as "25%"
wait: 2
```
When `limit` is specified, containers will be booted on, at most, `limit` hosts at once. MRSK will pause for `wait` seconds between batches.
These settings only apply when booting containers (using `mrsk deploy`, or `mrsk app boot`). For other commands, MRSK continues to run commands in parallel across all hosts.
## Hooks
You can run custom scripts at specific points with hooks.
Hooks should be stored in the .mrsk/hooks folder. Running mrsk init will build that folder and add some sample scripts.
You can change their location by setting `hooks_path` in the configuration file.
If the script returns a non-zero exit code the command will be aborted.
`MRSK_*` environment variables are available to the hooks command for
fine-grained audit reporting, e.g. for triggering deployment reports or
firing a JSON webhook. These variables include:
- `MRSK_RECORDED_AT` - UTC timestamp in ISO 8601 format, e.g. `2023-04-14T17:07:31Z`
- `MRSK_PERFORMER` - the local user performing the command (from `whoami`)
- `MRSK_SERVICE_VERSION` - an abbreviated service and version for use in messages, e.g. app@150b24f
- `MRSK_VERSION` - the full version being deployed
- `MRSK_HOSTS` - a comma-separated list of the hosts targeted by the command
- `MRSK_COMMAND` - The command we are running
- `MRSK_SUBCOMMAND` - optional: The subcommand we are running
- `MRSK_DESTINATION` - optional: destination, e.g. "staging"
- `MRSK_ROLE` - optional: role targeted, e.g. "web"
There are four hooks:
1. pre-connect
Called before taking the deploy lock. For checks that need to run before connecting to remote hosts - e.g. DNS warming.
2. pre-build
Used for pre-build checks - e.g. there are no uncommitted changes or that CI has passed.
3. pre-deploy
For final checks before deploying, e.g. checking CI completed
3. post-deploy - run after a deploy, redeploy or rollback.
This hook is also passed a `MRSK_RUNTIME` env variable set to the total seconds the deploy took.
This could be used to broadcast a deployment message, or register the new version with an APM.
The command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${MRSK_PERFORMER} Rolled back to version ${MRSK_VERSION}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like the following to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
Set `--skip_hooks` to avoid running the hooks.
## 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

@@ -8,7 +8,7 @@ require "mrsk"
begin begin
Mrsk::Cli::Main.start(ARGV) Mrsk::Cli::Main.start(ARGV)
rescue SSHKit::Runner::ExecuteError => e rescue SSHKit::Runner::ExecuteError => e
puts " \e[31mERROR (#{e.cause.class}): #{e.cause.message}\e[0m" puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m"
puts e.cause.backtrace if ENV["VERBOSE"] puts e.cause.backtrace if ENV["VERBOSE"]
exit 1 exit 1
rescue => e rescue => e

View File

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

View File

@@ -1,7 +1,7 @@
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, login: true)
with_lock do mutating do
if name == "all" if name == "all"
MRSK.accessory_names.each { |accessory_name| boot(accessory_name) } MRSK.accessory_names.each { |accessory_name| boot(accessory_name) }
else else
@@ -10,12 +10,10 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
upload(name) upload(name)
on(accessory.hosts) do on(accessory.hosts) do
execute *MRSK.registry.login execute *MRSK.registry.login if login
execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run execute *accessory.run
end end
audit_broadcast "Booted accessory #{name}" unless options[:skip_broadcast]
end end
end end
end end
@@ -23,7 +21,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
accessory.files.each do |(local, remote)| accessory.files.each do |(local, remote)|
@@ -40,7 +38,7 @@ 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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
accessory.directories.keys.each do |host_path| accessory.directories.keys.each do |host_path|
@@ -53,18 +51,22 @@ 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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do
execute *MRSK.registry.login
end
stop(name) stop(name)
remove_container(name) remove_container(name)
boot(name) boot(name, login: false)
end 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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
@@ -76,7 +78,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
@@ -88,7 +90,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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_lock do mutating do
with_accessory(name) do with_accessory(name) do
stop(name) stop(name)
start(name) start(name)
@@ -167,7 +169,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "remove [NAME]", "Remove accessory container, image and data directory 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)
with_lock do mutating do
if name == "all" if name == "all"
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) } MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
else else
@@ -185,7 +187,7 @@ 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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
@@ -197,7 +199,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug
@@ -209,7 +211,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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_lock do mutating do
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.hosts) do on(accessory.hosts) do
execute *accessory.remove_service_directory execute *accessory.remove_service_directory

View File

@@ -1,31 +1,39 @@
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
with_lock do mutating do
say "Get most recent version available as an image...", :magenta unless options[:version] hold_lock_on_error do
using_version(version_or_latest) 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 on(MRSK.hosts) do
execute *MRSK.auditor.record("Tagging #{MRSK.config.absolute_image} as the latest image"), verbosity: :debug
execute *MRSK.app.tag_current_as_latest
end
on(MRSK.hosts) do |host| on(MRSK.hosts, **MRSK.boot_strategy) do |host|
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
roles.each do |role| roles.each do |role|
execute *MRSK.auditor(role: role).record("Booted app version #{version}"), verbosity: :debug app = MRSK.app(role: role)
auditor = MRSK.auditor(role: role)
begin if capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).present?
if capture_with_info(*MRSK.app(role: role).container_id_for_version(version)).present? tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
tmp_version = "#{version}_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}" info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
execute *MRSK.auditor(role: role).record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
execute *MRSK.app(role: role).rename_container(version: version, new_version: tmp_version) execute *app.rename_container(version: version, new_version: tmp_version)
end end
old_version = capture_with_info(*MRSK.app(role: role).current_running_version).strip execute *auditor.record("Booted app version #{version}"), verbosity: :debug
execute *MRSK.app(role: role).run
sleep MRSK.config.readiness_delay old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *MRSK.app(role: role).stop(version: old_version), raise_on_non_zero_exit: false if old_version.present? execute *app.start_or_run(hostname: "#{host}-#{SecureRandom.hex(6)}")
Mrsk::Utils::HealthcheckPoller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
execute *app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
end end
end end
end end
@@ -35,7 +43,7 @@ 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
with_lock do mutating do
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
@@ -49,12 +57,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "stop", "Stop app container on servers" desc "stop", "Stop app container on servers"
def stop def stop
with_lock do mutating do
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
roles.each do |role| roles.each do |role|
execute *MRSK.auditor(role: role).record("Stopped app"), verbosity: :debug execute *MRSK.auditor.record("Stopped app", role: role), verbosity: :debug
execute *MRSK.app(role: role).stop, raise_on_non_zero_exit: false execute *MRSK.app(role: role).stop, raise_on_non_zero_exit: false
end end
end end
@@ -101,7 +109,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
roles.each do |role| roles.each do |role|
execute *MRSK.auditor(role: role).record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app(role: role).execute_in_existing_container(cmd)) puts_by_host host, capture_with_info(*MRSK.app(role: role).execute_in_existing_container(cmd))
end end
end end
@@ -124,6 +132,31 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end end
desc "stale_containers", "Detect app stale containers"
option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
def stale_containers
mutating do
stop = options[:stop]
cli = self
on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host)
roles.each do |role|
cli.send(:stale_versions, host: host, role: role).each do |version|
if stop
puts_by_host host, "Stopping stale container for role #{role} with version #{version}"
execute *MRSK.app(role: role).stop(version: version), raise_on_non_zero_exit: false
else
puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `mrsk app stale_containers --stop` to stop)"
end
end
end
end
end
end
desc "images", "Show app images on servers" desc "images", "Show app images on servers"
def images def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
@@ -169,7 +202,7 @@ 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
with_lock do mutating do
stop stop
remove_containers remove_containers
remove_images remove_images
@@ -178,12 +211,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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)
with_lock do mutating do
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
roles.each do |role| roles.each do |role|
execute *MRSK.auditor(role: role).record("Removed app container with version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
execute *MRSK.app(role: role).remove_container(version: version) execute *MRSK.app(role: role).remove_container(version: version)
end end
end end
@@ -192,12 +225,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
with_lock do mutating do
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
roles = MRSK.roles_on(host) roles = MRSK.roles_on(host)
roles.each do |role| roles.each do |role|
execute *MRSK.auditor(role: role).record("Removed all app containers"), verbosity: :debug execute *MRSK.auditor.record("Removed all app containers", role: role), verbosity: :debug
execute *MRSK.app(role: role).remove_containers execute *MRSK.app(role: role).remove_containers
end end
end end
@@ -206,7 +239,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
with_lock do mutating do
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images execute *MRSK.app.remove_images
@@ -240,6 +273,17 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
version.presence version.presence
end end
def stale_versions(host:, role:)
versions = nil
on(host) do
versions = \
capture_with_info(*MRSK.app(role: role).list_versions, raise_on_non_zero_exit: false)
.split("\n")
.drop(1)
end
versions
end
def version_or_latest def version_or_latest
options[:version] || "latest" options[:version] || "latest"
end end

View File

@@ -20,7 +20,7 @@ module Mrsk::Cli
class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file" class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)" class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
class_option :skip_broadcast, aliases: "-B", type: :boolean, default: false, desc: "Skip audit broadcasts" class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks"
def initialize(*) def initialize(*)
super super
@@ -72,42 +72,100 @@ module Mrsk::Cli
puts " Finished all in #{sprintf("%.1f seconds", runtime)}" puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end end
def audit_broadcast(line) def mutating
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug } return yield if MRSK.holding_lock?
end
MRSK.config.ensure_env_available
run_hook "pre-connect"
def with_lock
acquire_lock acquire_lock
yield begin
yield
rescue
if MRSK.hold_lock_on_error?
error " \e[31mDeploy lock was not released\e[0m"
else
release_lock
end
raise
end
release_lock release_lock
rescue
error " \e[31mDeploy lock was not released\e[0m" if MRSK.lock_count > 0
raise
end end
def acquire_lock def acquire_lock
if MRSK.lock_count == 0 raise_if_locked do
say "Acquiring the deploy lock" say "Acquiring the deploy lock...", :magenta
on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version) } on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version), verbosity: :debug }
end end
MRSK.lock_count += 1
MRSK.holding_lock = true
end
def release_lock
say "Releasing the deploy lock...", :magenta
on(MRSK.primary_host) { execute *MRSK.lock.release, verbosity: :debug }
MRSK.holding_lock = false
end
def raise_if_locked
yield
rescue SSHKit::Runner::ExecuteError => e rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /cannot create directory/ if e.message =~ /cannot create directory/
invoke "mrsk:cli:lock:status", [] on(MRSK.primary_host) { puts capture_with_debug(*MRSK.lock.status) }
raise LockError, "Deploy lock found" raise LockError, "Deploy lock found"
else else
raise e raise e
end end
end end
def release_lock def hold_lock_on_error
MRSK.lock_count -= 1 if MRSK.hold_lock_on_error?
if MRSK.lock_count == 0 yield
say "Releasing the deploy lock" else
on(MRSK.primary_host) { execute *MRSK.lock.release } MRSK.hold_lock_on_error = true
yield
MRSK.hold_lock_on_error = false
end end
end end
end
def run_hook(hook, **extra_details)
if !options[:skip_hooks] && MRSK.hook.hook_exists?(hook)
details = { hosts: MRSK.hosts.join(","), command: command, subcommand: subcommand }
say "Running the #{hook} hook...", :magenta
run_locally do
MRSK.with_verbosity(:debug) { execute *MRSK.hook.run(hook, **details, **extra_details) }
rescue SSHKit::Command::Failed
raise HookError.new("Hook `#{hook}` failed")
end
end
end
def command
@mrsk_command ||= begin
invocation_class, invocation_commands = *first_invocation
if invocation_class == Mrsk::Cli::Main
invocation_commands[0]
else
Mrsk::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }[0]
end
end
end
def subcommand
@mrsk_subcommand ||= begin
invocation_class, invocation_commands = *first_invocation
invocation_commands[0] if invocation_class != Mrsk::Cli::Main
end
end
def first_invocation
instance_variable_get("@_invocations").first
end
end
end end

View File

@@ -1,7 +1,9 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
class BuildError < StandardError; end
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
with_lock do mutating do
push push
pull pull
end end
@@ -9,9 +11,12 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "push", "Build and push app image to registry" desc "push", "Build and push app image to registry"
def push def push
with_lock do mutating do
cli = self cli = self
verify_local_dependencies
run_hook "pre-build"
run_locally do run_locally do
begin begin
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
@@ -32,7 +37,7 @@ 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
with_lock do mutating do
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("Pulled image with version #{MRSK.config.version}"), verbosity: :debug execute *MRSK.auditor.record("Pulled image with version #{MRSK.config.version}"), verbosity: :debug
execute *MRSK.builder.clean, raise_on_non_zero_exit: false execute *MRSK.builder.clean, raise_on_non_zero_exit: false
@@ -43,7 +48,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "create", "Create a build setup" desc "create", "Create a build setup"
def create def create
with_lock do mutating do
run_locally do run_locally do
begin begin
debug "Using builder: #{MRSK.builder.name}" debug "Using builder: #{MRSK.builder.name}"
@@ -62,7 +67,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "remove", "Remove build setup" desc "remove", "Remove build setup"
def remove def remove
with_lock do mutating do
run_locally do run_locally do
debug "Using builder: #{MRSK.builder.name}" debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove execute *MRSK.builder.remove
@@ -77,4 +82,19 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
puts capture(*MRSK.builder.info) puts capture(*MRSK.builder.info)
end end
end end
private
def verify_local_dependencies
run_locally do
begin
execute *MRSK.builder.ensure_local_dependencies_installed
rescue SSHKit::Command::Failed => e
build_error = e.message =~ /command not found/ ?
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise BuildError, build_error
end
end
end
end end

View File

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

View File

@@ -2,16 +2,16 @@ class Mrsk::Cli::Lock < Mrsk::Cli::Base
desc "status", "Report lock status" desc "status", "Report lock status"
def status def status
handle_missing_lock do handle_missing_lock do
on(MRSK.primary_host) { puts capture_with_info(*MRSK.lock.status) } on(MRSK.primary_host) { puts capture_with_debug(*MRSK.lock.status) }
end end
end end
desc "acquire", "Acquire the deploy lock" desc "acquire", "Acquire the deploy lock"
option :message, aliases: "-m", type: :string, desc: "A lock mesasge", required: true option :message, aliases: "-m", type: :string, desc: "A lock message", required: true
def acquire def acquire
message = options[:message] message = options[:message]
handle_missing_lock do raise_if_locked do
on(MRSK.primary_host) { execute *MRSK.lock.acquire(message, MRSK.config.version) } on(MRSK.primary_host) { execute *MRSK.lock.acquire(message, MRSK.config.version), verbosity: :debug }
say "Acquired the deploy lock" say "Acquired the deploy lock"
end end
end end
@@ -19,7 +19,7 @@ class Mrsk::Cli::Lock < Mrsk::Cli::Base
desc "release", "Release the deploy lock" desc "release", "Release the deploy lock"
def release def release
handle_missing_lock do handle_missing_lock do
on(MRSK.primary_host) { execute *MRSK.lock.release } on(MRSK.primary_host) { execute *MRSK.lock.release, verbosity: :debug }
say "Released the deploy lock" say "Released the deploy lock"
end end
end end

View File

@@ -1,8 +1,8 @@
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
with_lock do print_runtime do
print_runtime do mutating do
invoke "mrsk:cli:server:bootstrap" invoke "mrsk:cli:server:bootstrap"
invoke "mrsk:cli:accessory:boot", [ "all" ] invoke "mrsk:cli:accessory:boot", [ "all" ]
deploy deploy
@@ -13,12 +13,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
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" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy def deploy
with_lock do runtime = print_runtime do
invoke_options = deploy_options mutating do
invoke_options = deploy_options
runtime = print_runtime do
say "Ensure curl and Docker are installed...", :magenta
invoke "mrsk:cli:server:bootstrap", [], invoke_options
say "Log into image registry...", :magenta say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login", [], invoke_options invoke "mrsk:cli:registry:login", [], invoke_options
@@ -31,29 +28,34 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
invoke "mrsk:cli:build:deliver", [], invoke_options invoke "mrsk:cli:build:deliver", [], invoke_options
end end
run_hook "pre-deploy"
say "Ensure Traefik is running...", :magenta say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot", [], invoke_options invoke "mrsk:cli:traefik:boot", [], invoke_options
say "Ensure app can pass healthcheck...", :magenta say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform", [], invoke_options invoke "mrsk:cli:healthcheck:perform", [], invoke_options
say "Detect stale containers...", :magenta
invoke "mrsk:cli:app:stale_containers", [], invoke_options
invoke "mrsk:cli:app:boot", [], invoke_options invoke "mrsk:cli:app:boot", [], invoke_options
say "Prune old containers and images...", :magenta say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all", [], invoke_options invoke "mrsk:cli:prune:all", [], invoke_options
end end
audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end end
run_hook "post-deploy", runtime: runtime.round
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" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy def redeploy
with_lock do runtime = print_runtime do
invoke_options = deploy_options mutating do
invoke_options = deploy_options
runtime = print_runtime do
if options[:skip_push] if options[:skip_push]
say "Pull app image...", :magenta say "Pull app image...", :magenta
invoke "mrsk:cli:build:pull", [], invoke_options invoke "mrsk:cli:build:pull", [], invoke_options
@@ -62,49 +64,43 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
invoke "mrsk:cli:build:deliver", [], invoke_options invoke "mrsk:cli:build:deliver", [], invoke_options
end end
run_hook "pre-deploy"
say "Ensure app can pass healthcheck...", :magenta say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform", [], invoke_options invoke "mrsk:cli:healthcheck:perform", [], invoke_options
say "Detect stale containers...", :magenta
invoke "mrsk:cli:app:stale_containers", [], invoke_options
invoke "mrsk:cli:app:boot", [], invoke_options invoke "mrsk:cli:app:boot", [], invoke_options
end end
audit_broadcast "Redeployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end end
run_hook "post-deploy", runtime: runtime.round
end end
desc "rollback [VERSION]", "Rollback app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version) def rollback(version)
with_lock do rolled_back = false
MRSK.config.version = version runtime = print_runtime do
mutating do
invoke_options = deploy_options
if container_available?(version) MRSK.config.version = version
say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
cli = self
old_version = nil old_version = nil
on(MRSK.hosts) do |host| if container_available?(version)
roles = MRSK.roles_on(host) run_hook "pre-deploy"
roles.each do |role| invoke "mrsk:cli:app:boot", [], invoke_options.merge(version: version)
app = MRSK.app(role: role) rolled_back = true
old_version = capture_with_info(*app.current_running_version).strip.presence else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
execute *app.start
if old_version
sleep MRSK.config.readiness_delay
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
end
end
end end
audit_broadcast "Rolled back #{service_version(Mrsk::Utils.abbreviate_version(old_version))} to #{service_version}" unless options[:skip_broadcast]
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end end
end end
run_hook "post-deploy", runtime: runtime.round if rolled_back
end end
desc "details", "Show details about all containers" desc "details", "Show details about all containers"
@@ -146,6 +142,14 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
puts "Created .env file" puts "Created .env file"
end end
unless (hooks_dir = Pathname.new(File.expand_path(".mrsk/hooks"))).exist?
hooks_dir.mkpath
Pathname.new(File.expand_path("templates/sample_hooks", __dir__)).each_child do |sample_hook|
FileUtils.cp sample_hook, hooks_dir, preserve: true
end
puts "Created sample hooks in .mrsk/hooks"
end
if options[:bundle] if options[:bundle]
if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist? if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
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)"
@@ -176,7 +180,7 @@ 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
with_lock do mutating do
if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y" if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed) invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:app:remove", [], options.without(:confirmed) invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
@@ -203,6 +207,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "healthcheck", "Healthcheck application" desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "lock", "Manage the deploy lock"
subcommand "lock", Mrsk::Cli::Lock
desc "prune", "Prune old application images and containers" desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune subcommand "prune", Mrsk::Cli::Prune
@@ -215,26 +222,28 @@ 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_available?(version, host: MRSK.primary_host) def container_available?(version)
available = nil begin
on(MRSK.hosts) do
on(host) do MRSK.roles_on(host).each do |role|
first_role = MRSK.roles_on(host).first container_id = capture_with_info(*MRSK.app(role: role).container_id_for_version(version))
available = capture_with_info(*MRSK.app(role: first_role).container_id_for_version(version)).present? raise "Container not found" unless container_id.present?
end
end
rescue SSHKit::Runner::ExecuteError => e
if e.message =~ /Container not found/
say "Error looking for container version #{version}: #{e.message}"
return false
else
raise
end
end end
available true
end end
def deploy_options def deploy_options
{ "version" => MRSK.config.version }.merge(options.without("skip_push")) { "version" => MRSK.config.version }.merge(options.without("skip_push"))
end end
def service_version(version = MRSK.config.abbreviated_version)
[ MRSK.config.service, version ].compact.join("@")
end
end end

View File

@@ -1,25 +1,26 @@
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
with_lock do mutating do
containers containers
images images
end end
end end
desc "images", "Prune unused images older than 7 days" desc "images", "Prune dangling images"
def images def images
with_lock do mutating do
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned images"), verbosity: :debug execute *MRSK.auditor.record("Pruned images"), verbosity: :debug
execute *MRSK.prune.images execute *MRSK.prune.dangling_images
execute *MRSK.prune.tagged_images
end end
end end
end end
desc "containers", "Prune stopped containers older than 3 days" desc "containers", "Prune all stopped containers, except the last 5"
def containers def containers
with_lock do mutating do
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned containers"), verbosity: :debug execute *MRSK.auditor.record("Pruned containers"), verbosity: :debug
execute *MRSK.prune.containers execute *MRSK.prune.containers

View File

@@ -1,17 +1,21 @@
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", "Set up Docker to run MRSK apps"
def bootstrap def bootstrap
with_lock do missing = []
on(MRSK.hosts + MRSK.accessory_hosts) do
dependencies_to_install = Array.new.tap do |dependencies|
dependencies << "curl" unless execute "which curl", raise_on_non_zero_exit: false
dependencies << "docker.io" unless execute "which docker", raise_on_non_zero_exit: false
end
if dependencies_to_install.any? on(MRSK.hosts | MRSK.accessory_hosts) do |host|
execute "apt-get update -y && apt-get install #{dependencies_to_install.join(" ")} -y" unless execute(*MRSK.docker.installed?, raise_on_non_zero_exit: false)
if execute(*MRSK.docker.superuser?, raise_on_non_zero_exit: false)
info "Missing Docker on #{host}. Installing…"
execute *MRSK.docker.install
else
missing << host
end end
end end
end end
if missing.any?
raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/"
end
end end
end end

View File

@@ -25,10 +25,6 @@ registry:
# secret: # secret:
# - RAILS_MASTER_KEY # - RAILS_MASTER_KEY
# Call a broadcast command on deploys.
# audit_broadcast_cmd:
# bin/broadcast_to_bc
# Use a different ssh user than root # Use a different ssh user than root
# ssh: # ssh:
# user: app # user: app

View File

@@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# MRSK_RECORDED_AT
# MRSK_PERFORMER
# MRSK_VERSION
# MRSK_HOSTS
# MRSK_ROLE (if set)
# MRSK_DESTINATION (if set)
# MRSK_RUNTIME
echo "$MRSK_PERFORMER deployed $MRSK_VERSION to $MRSK_DESTINATION in $MRSK_RUNTIME seconds"

View File

@@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# MRSK_RECORDED_AT
# MRSK_PERFORMER
# MRSK_VERSION
# MRSK_HOSTS
# MRSK_ROLE (if set)
# MRSK_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$MRSK_VERSION" != "$remote_head" ]; then
echo "Version ($MRSK_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# MRSK_RECORDED_AT
# MRSK_PERFORMER
# MRSK_VERSION
# MRSK_HOSTS
# MRSK_ROLE (if set)
# MRSK_DESTINATION (if set)
# MRSK_RUNTIME
hosts = ENV["MRSK_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# MRSK_RECORDED_AT
# MRSK_PERFORMER
# MRSK_VERSION
# MRSK_HOSTS
# MRSK_COMMAND
# MRSK_SUBCOMMAND
# MRSK_ROLE (if set)
# MRSK_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["MRSK_COMMAND"] == "rollback" || ENV["MRSK_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
end
$stdout.sync = true
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
begin
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@@ -1,7 +1,7 @@
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
with_lock do mutating do
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.registry.login execute *MRSK.registry.login
execute *MRSK.traefik.run, raise_on_non_zero_exit: false execute *MRSK.traefik.run, raise_on_non_zero_exit: false
@@ -10,17 +10,22 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
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)"
option :rolling, type: :boolean, default: false, desc: "Reboot traefik on hosts in sequence, rather than in parallel"
def reboot def reboot
with_lock do mutating do
stop on(MRSK.traefik_hosts, in: options[:rolling] ? :sequence : :parallel) do
remove_container execute *MRSK.auditor.record("Rebooted traefik"), verbosity: :debug
boot execute *MRSK.registry.login
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
execute *MRSK.traefik.remove_container
execute *MRSK.traefik.run, raise_on_non_zero_exit: false
end
end end
end end
desc "start", "Start existing Traefik container on servers" desc "start", "Start existing Traefik container on servers"
def start def start
with_lock do mutating do
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Started traefik"), verbosity: :debug execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false execute *MRSK.traefik.start, raise_on_non_zero_exit: false
@@ -30,7 +35,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "stop", "Stop existing Traefik container on servers" desc "stop", "Stop existing Traefik container on servers"
def stop def stop
with_lock do mutating do
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
@@ -40,7 +45,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "restart", "Restart existing Traefik container on servers" desc "restart", "Restart existing Traefik container on servers"
def restart def restart
with_lock do mutating do
stop stop
start start
end end
@@ -77,7 +82,7 @@ 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
with_lock do mutating do
stop stop
remove_container remove_container
remove_image remove_image
@@ -86,7 +91,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
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
with_lock do mutating do
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container execute *MRSK.traefik.remove_container
@@ -94,9 +99,9 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
end end
end end
desc "remove_container", "Remove Traefik image from servers", hide: true desc "remove_image", "Remove Traefik image from servers", hide: true
def remove_image def remove_image
with_lock do mutating do
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image execute *MRSK.traefik.remove_image

View File

@@ -2,11 +2,12 @@ require "active_support/core_ext/enumerable"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
class Mrsk::Commander class Mrsk::Commander
attr_accessor :verbosity, :lock_count attr_accessor :verbosity, :holding_lock, :hold_lock_on_error
def initialize def initialize
self.verbosity = :info self.verbosity = :info
self.lock_count = 0 self.holding_lock = false
self.hold_lock_on_error = false
end end
def config def config
@@ -35,7 +36,7 @@ class Mrsk::Commander
end end
def primary_host def primary_host
specific_hosts&.first || config.primary_web_host specific_hosts&.first || specific_roles&.first&.primary_host || config.primary_web_host
end end
def roles def roles
@@ -50,6 +51,14 @@ class Mrsk::Commander
end end
end end
def boot_strategy
if config.boot.limit.present?
{ in: :groups, limit: config.boot.limit, wait: config.boot.wait }
else
{}
end
end
def roles_on(host) def roles_on(host)
roles.select { |role| role.hosts.include?(host.to_s) }.map(&:name) roles.select { |role| role.hosts.include?(host.to_s) }.map(&:name)
end end
@@ -75,18 +84,30 @@ class Mrsk::Commander
Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end end
def auditor(role: nil) def auditor(**details)
Mrsk::Commands::Auditor.new(config, role: role) Mrsk::Commands::Auditor.new(config, **details)
end end
def builder def builder
@builder ||= Mrsk::Commands::Builder.new(config) @builder ||= Mrsk::Commands::Builder.new(config)
end end
def docker
@docker ||= Mrsk::Commands::Docker.new(config)
end
def healthcheck def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config) @healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end end
def hook
@hook ||= Mrsk::Commands::Hook.new(config)
end
def lock
@lock ||= Mrsk::Commands::Lock.new(config)
end
def prune def prune
@prune ||= Mrsk::Commands::Prune.new(config) @prune ||= Mrsk::Commands::Prune.new(config)
end end
@@ -99,10 +120,6 @@ 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
@@ -115,6 +132,14 @@ class Mrsk::Commander
SSHKit.config.output_verbosity = old_level SSHKit.config.output_verbosity = old_level
end end
def holding_lock?
self.holding_lock
end
def hold_lock_on_error?
self.hold_lock_on_error
end
private private
# Lazy setup of SSHKit # Lazy setup of SSHKit
def configure_sshkit_with(config) def configure_sshkit_with(config)

View File

@@ -1,4 +1,6 @@
class Mrsk::Commands::App < Mrsk::Commands::Base class Mrsk::Commands::App < Mrsk::Commands::Base
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
attr_reader :role attr_reader :role
def initialize(config, role: nil) def initialize(config, role: nil)
@@ -6,15 +8,21 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
@role = role @role = role
end end
def run def start_or_run(hostname: nil)
combine start, run(hostname: hostname), by: "||"
end
def run(hostname: nil)
role = config.role(self.role) role = config.role(self.role)
docker :run, docker :run,
"--detach", "--detach",
"--restart unless-stopped", "--restart unless-stopped",
"--name", container_name, "--name", container_name,
*(["--hostname", hostname] if hostname),
"-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"", "-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"",
*role.env_args, *role.env_args,
*role.health_check_args,
*config.logging_args, *config.logging_args,
*config.volume_args, *config.volume_args,
*role.label_args, *role.label_args,
@@ -27,9 +35,13 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :start, container_name docker :start, container_name
end end
def status(version:)
pipe container_id_for_version(version), xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
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_running_container_id,
xargs(config.stop_wait_time ? docker(:stop, "-t", config.stop_wait_time) : docker(:stop)) xargs(config.stop_wait_time ? docker(:stop, "-t", config.stop_wait_time) : docker(:stop))
end end
@@ -40,7 +52,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
current_container_id, current_running_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
@@ -48,7 +60,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def follow_logs(host:, grep: nil) def follow_logs(host:, grep: nil)
run_over_ssh \ run_over_ssh \
pipe( pipe(
current_container_id, current_running_container_id,
"xargs docker logs --timestamps --tail 10 --follow 2>&1", "xargs docker logs --timestamps --tail 10 --follow 2>&1",
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
), ),
@@ -82,20 +94,23 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def current_container_id def current_running_container_id
docker :ps, "--quiet", *filter_args docker :ps, "--quiet", *filter_args(statuses: ACTIVE_DOCKER_STATUSES), "--latest"
end end
def container_id_for_version(version) def container_id_for_version(version, only_running: false)
container_id_for(container_name: container_name(version)) container_id_for(container_name: container_name(version), only_running: only_running)
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! list_versions("--latest", statuses: ACTIVE_DOCKER_STATUSES)
end
def list_versions(*docker_args, statuses: nil)
pipe \ pipe \
docker(:ps, *filter_args, "--format", '"{{.Names}}"'), docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
%(sed 's/-/\\n/g'), %(grep -oE "\\-[^-]+$"), # Extract SHA from "service-role-dest-SHA"
"tail -n 1" %(cut -c 2-)
end end
def list_containers def list_containers
@@ -128,20 +143,27 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :image, :prune, "--all", "--force", *filter_args docker :image, :prune, "--all", "--force", *filter_args
end end
def tag_current_as_latest
docker :tag, config.absolute_image, config.latest_image
end
private private
def container_name(version = nil) def container_name(version = nil)
[ config.service, role, config.destination, version || config.version ].compact.join("-") [ config.service, role, config.destination, version || config.version ].compact.join("-")
end end
def filter_args def filter_args(statuses: nil)
argumentize "--filter", filters argumentize "--filter", filters(statuses: statuses)
end end
def filters def filters(statuses: nil)
[ "label=service=#{config.service}" ].tap do |filters| [ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination filters << "label=destination=#{config.destination}" if config.destination
filters << "label=role=#{role}" if role filters << "label=role=#{role}" if role
statuses&.each do |status|
filters << "status=#{status}"
end
end end
end end
end end

View File

@@ -1,27 +1,18 @@
require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base class Mrsk::Commands::Auditor < Mrsk::Commands::Base
attr_reader :role attr_reader :details
def initialize(config, role: nil) def initialize(config, **details)
super(config) super(config)
@role = role @details = details
end end
# Runs remotely # Runs remotely
def record(line) def record(line, **details)
append \ append \
[ :echo, tagged_record_line(line) ], [ :echo, audit_tags(**details).except(:version, :service_version).to_s, line ],
audit_log_file audit_log_file
end end
# Runs locally
def broadcast(line)
if broadcast_cmd = config.audit_broadcast_cmd
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
def reveal def reveal
[ :tail, "-n", 50, audit_log_file ] [ :tail, "-n", 50, audit_log_file ]
end end
@@ -31,27 +22,7 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
[ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-") [ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-")
end end
def tagged_record_line(line) def audit_tags(**details)
tagged_line recorded_at_tag, performer_tag, role_tag, line tags(**self.details, **details)
end
def tagged_broadcast_line(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
def performer_tag
"[#{`whoami`.strip}]"
end
def role_tag
"[#{role}]" if role
end end
end end

View File

@@ -2,6 +2,9 @@ module Mrsk::Commands
class Base class Base
delegate :sensitive, :argumentize, to: Mrsk::Utils delegate :sensitive, :argumentize, to: Mrsk::Utils
DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'"
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
attr_accessor :config attr_accessor :config
def initialize(config) def initialize(config)
@@ -10,13 +13,17 @@ module Mrsk::Commands
def run_over_ssh(*command, host:) def run_over_ssh(*command, host:)
"ssh".tap do |cmd| "ssh".tap do |cmd|
cmd << " -J #{config.ssh_proxy.jump_proxies}" if config.ssh_proxy if config.ssh_proxy && config.ssh_proxy.is_a?(Net::SSH::Proxy::Jump)
cmd << " -J #{config.ssh_proxy.jump_proxies}"
elsif config.ssh_proxy && config.ssh_proxy.is_a?(Net::SSH::Proxy::Command)
cmd << " -o ProxyCommand='#{config.ssh_proxy.command_line_template}'"
end
cmd << " -t #{config.ssh_user}@#{host} '#{command.join(" ")}'" cmd << " -t #{config.ssh_user}@#{host} '#{command.join(" ")}'"
end end
end end
def container_id_for(container_name:) def container_id_for(container_name:, only_running: false)
docker :container, :ls, "--all", "--filter", "name=^#{container_name}$", "--quiet" docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
end end
private private
@@ -50,5 +57,9 @@ module Mrsk::Commands
def docker(*args) def docker(*args)
args.compact.unshift :docker args.compact.unshift :docker
end end
def tags(**details)
Mrsk::Tags.from_config(config, **details)
end
end end
end end

View File

@@ -2,16 +2,18 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, to: :target delegate :create, :remove, :push, :clean, :pull, :info, to: :target
def name def name
target.class.to_s.remove("Mrsk::Commands::Builder::").underscore target.class.to_s.remove("Mrsk::Commands::Builder::").underscore.inquiry
end end
def target def target
case case
when config.builder && config.builder["multiarch"] == false when !config.builder.multiarch? && !config.builder.cached?
native native
when config.builder && config.builder["local"] && config.builder["remote"] when !config.builder.multiarch? && config.builder.cached?
native_cached
when config.builder.local? && config.builder.remote?
multiarch_remote multiarch_remote
when config.builder && config.builder["remote"] when config.builder.remote?
native_remote native_remote
else else
multiarch multiarch
@@ -22,6 +24,10 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
@native ||= Mrsk::Commands::Builder::Native.new(config) @native ||= Mrsk::Commands::Builder::Native.new(config)
end end
def native_cached
@native ||= Mrsk::Commands::Builder::Native::Cached.new(config)
end
def native_remote def native_remote
@native ||= Mrsk::Commands::Builder::Native::Remote.new(config) @native ||= Mrsk::Commands::Builder::Native::Remote.new(config)
end end
@@ -33,4 +39,24 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
def multiarch_remote def multiarch_remote
@multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config) @multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config)
end end
def ensure_local_dependencies_installed
if name.native?
ensure_local_docker_installed
else
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
end
private
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end
end end

View File

@@ -1,5 +1,9 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
class BuilderError < StandardError; end
delegate :argumentize, to: Mrsk::Utils delegate :argumentize, to: Mrsk::Utils
delegate :args, :secrets, :dockerfile, :local_arch, :local_host, :remote_arch, :remote_host, :cache_from, :cache_to, to: :builder_config
def clean def clean
docker :image, :rm, "--force", config.absolute_image docker :image, :rm, "--force", config.absolute_image
@@ -7,22 +11,29 @@ 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
[ *build_tags, *build_labels, *build_args, *build_secrets, *build_dockerfile ] [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile ]
end end
def build_context def build_context
context config.builder.context
end end
private private
def build_tags def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ] [ "-t", config.absolute_image, "-t", config.latest_image ]
end end
def build_cache
if cache_to && cache_from
["--cache-to", cache_to,
"--cache-from", cache_from]
end
end
def build_labels def build_labels
argumentize "--label", { service: config.service } argumentize "--label", { service: config.service }
end end
@@ -36,22 +47,14 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
end end
def build_dockerfile def build_dockerfile
argumentize "--file", dockerfile if Pathname.new(File.expand_path(dockerfile)).exist?
argumentize "--file", dockerfile
else
raise BuilderError, "Missing #{dockerfile}"
end
end end
def args def builder_config
(config.builder && config.builder["args"]) || {} config.builder
end
def secrets
(config.builder && config.builder["secrets"]) || []
end
def dockerfile
(config.builder && config.builder["dockerfile"]) || "Dockerfile"
end
def context
(config.builder && config.builder["context"]) || "."
end end
end end

View File

@@ -22,17 +22,17 @@ class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Mult
end end
def create_local_buildx def create_local_buildx
docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local["arch"]), "--platform", "linux/#{local["arch"]}" docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local_arch), "--platform", "linux/#{local_arch}"
end end
def append_remote_buildx def append_remote_buildx
docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote["arch"]), "--platform", "linux/#{remote["arch"]}" docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote_arch), "--platform", "linux/#{remote_arch}"
end end
def create_contexts def create_contexts
combine \ combine \
create_context(local["arch"], local["host"]), create_context(local_arch, local_host),
create_context(remote["arch"], remote["host"]) create_context(remote_arch, remote_host)
end end
def create_context(arch, host) def create_context(arch, host)
@@ -41,19 +41,11 @@ class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Mult
def remove_contexts def remove_contexts
combine \ combine \
remove_context(local["arch"]), remove_context(local_arch),
remove_context(remote["arch"]) remove_context(remote_arch)
end end
def remove_context(arch) def remove_context(arch)
docker :context, :rm, builder_name_with_arch(arch) docker :context, :rm, builder_name_with_arch(arch)
end end
def local
config.builder["local"]
end
def remote
config.builder["remote"]
end
end end

View File

@@ -1,10 +1,10 @@
class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def create def create
# No-op on native # No-op on native without cache
end end
def remove def remove
# No-op on native # No-op on native without cache
end end
def push def push

View File

@@ -0,0 +1,16 @@
class Mrsk::Commands::Builder::Native::Cached < Mrsk::Commands::Builder::Native
def create
docker :buildx, :create, "--use", "--driver=docker-container"
end
def remove
docker :buildx, :rm, builder_name
end
def push
docker :buildx, :build,
"--push",
*build_options,
build_context
end
end

View File

@@ -28,29 +28,21 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
private private
def arch
config.builder["remote"]["arch"]
end
def host
config.builder["remote"]["host"]
end
def builder_name def builder_name
"mrsk-#{config.service}-native-remote" "mrsk-#{config.service}-native-remote"
end end
def builder_name_with_arch def builder_name_with_arch
"#{builder_name}-#{arch}" "#{builder_name}-#{remote_arch}"
end end
def platform def platform
"linux/#{arch}" "linux/#{remote_arch}"
end end
def create_context def create_context
docker :context, :create, docker :context, :create,
builder_name_with_arch, "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'" builder_name_with_arch, "--description", "'#{builder_name} #{remote_arch} native host'", "--docker", "'host=#{remote_host}'"
end end
def remove_context def remove_context

View File

@@ -0,0 +1,21 @@
class Mrsk::Commands::Docker < Mrsk::Commands::Base
# Install Docker using the https://github.com/docker/docker-install convenience script.
def install
pipe [ :curl, "-fsSL", "https://get.docker.com" ], :sh
end
# Checks the Docker client version. Fails if Docker is not installed.
def installed?
docker "-v"
end
# Checks the Docker server version. Fails if Docker is not running.
def running?
docker :version
end
# Do we have superuser access to install Docker and start system services?
def superuser?
[ '[ "${EUID:-$(id -u)}" -eq 0 ]' ]
end
end

View File

@@ -11,14 +11,19 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
"--label", "service=#{container_name}", "--label", "service=#{container_name}",
"-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"", "-e", "MRSK_CONTAINER_NAME=\"#{container_name}\"",
*web.env_args, *web.env_args,
*web.health_check_args,
*config.volume_args, *config.volume_args,
*web.option_args, *web.option_args,
config.absolute_image, config.absolute_image,
web.cmd web.cmd
end end
def curl def status
[ :curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", health_url ] pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
end
def container_health_log
pipe container_id, xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT))
end end
def logs def logs

14
lib/mrsk/commands/hook.rb Normal file
View File

@@ -0,0 +1,14 @@
class Mrsk::Commands::Hook < Mrsk::Commands::Base
def run(hook, **details)
[ hook_file(hook), env: tags(**details).env ]
end
def hook_exists?(hook)
Pathname.new(hook_file(hook)).exist?
end
private
def hook_file(hook)
"#{config.hooks_path}/#{hook}"
end
end

View File

@@ -1,5 +1,5 @@
require "active_support/duration" require "active_support/duration"
require "active_support/core_ext/numeric/time" require "time"
class Mrsk::Commands::Lock < Mrsk::Commands::Base class Mrsk::Commands::Lock < Mrsk::Commands::Base
def acquire(message, version) def acquire(message, version)
@@ -40,7 +40,7 @@ class Mrsk::Commands::Lock < Mrsk::Commands::Base
end end
def lock_dir def lock_dir
:mrsk_lock "mrsk_lock-#{config.service}"
end end
def lock_details_file def lock_details_file
@@ -49,7 +49,7 @@ class Mrsk::Commands::Lock < Mrsk::Commands::Base
def lock_details(message, version) def lock_details(message, version)
<<~DETAILS.strip <<~DETAILS.strip
Locked by: #{locked_by} at #{Time.now.gmtime} Locked by: #{locked_by} at #{Time.now.utc.iso8601}
Version: #{version} Version: #{version}
Message: #{message} Message: #{message}
DETAILS DETAILS

View File

@@ -2,11 +2,37 @@ require "active_support/duration"
require "active_support/core_ext/numeric/time" require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Prune < Mrsk::Commands::Base class Mrsk::Commands::Prune < Mrsk::Commands::Base
def images(until_hours: 7.days.in_hours.to_i) def dangling_images
docker :image, :prune, "--all", "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h" docker :image, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "dangling=true"
end end
def containers(until_hours: 3.days.in_hours.to_i) def tagged_images
docker :container, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h" pipe \
docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"),
"grep -v -w \"#{active_image_list}\"",
"while read image tag; do docker rmi $tag; done"
end end
def containers(keep_last: 5)
pipe \
docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters),
"tail -n +#{keep_last + 1}",
"while read container_id; do docker rm $container_id; done"
end
private
def stopped_containers_filters
[ "created", "exited", "dead" ].flat_map { |status| ["--filter", "status=#{status}"] }
end
def active_image_list
# Pull the images that are used by any containers
# Append repo:latest - to avoid deleting the latest tag
# Append repo:<none> - to avoid deleting dangling images that are in use. Unused dangling images are deleted separately
"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=#{config.service} | tr -d '\\n')#{config.latest_image}\\|#{config.repository}:<none>"
end
def service_filter
[ "--filter", "label=service=#{config.service}" ]
end
end end

View File

@@ -1,8 +1,11 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
delegate :argumentize, :optionize, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
DEFAULT_IMAGE = "traefik:v2.9" DEFAULT_IMAGE = "traefik:v2.9"
CONTAINER_PORT = 80 CONTAINER_PORT = 80
DEFAULT_ARGS = {
'log.level' => 'DEBUG'
}
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
@@ -10,12 +13,12 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--publish", port, "--publish", port,
"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
*env_args,
*config.logging_args, *config.logging_args,
*label_args, *label_args,
*docker_options_args, *docker_options_args,
image, image,
"--providers.docker", "--providers.docker",
"--log.level=DEBUG",
*cmd_option_args *cmd_option_args
end end
@@ -61,6 +64,16 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
argumentize "--label", labels argumentize "--label", labels
end end
def env_args
env_config = config.traefik["env"] || {}
if env_config.present?
argumentize_env_with_secrets(env_config)
else
[]
end
end
def labels def labels
config.traefik["labels"] || [] config.traefik["labels"] || []
end end
@@ -75,9 +88,9 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def cmd_option_args def cmd_option_args
if args = config.traefik["args"] if args = config.traefik["args"]
optionize args, with: "=" optionize DEFAULT_ARGS.merge(args), with: "="
else else
[] optionize DEFAULT_ARGS, with: "="
end end
end end

View File

@@ -6,7 +6,7 @@ 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, :stop_wait_time, to: :raw_config, allow_nil: true delegate :service, :image, :servers, :env, :labels, :registry, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
attr_accessor :destination attr_accessor :destination
@@ -50,7 +50,7 @@ class Mrsk::Configuration
end end
def version def version
@declared_version.presence || ENV["VERSION"] || current_commit_hash @declared_version.presence || ENV["VERSION"] || git_version
end end
def abbreviated_version def abbreviated_version
@@ -87,6 +87,10 @@ class Mrsk::Configuration
roles.select(&:running_traefik?).flat_map(&:hosts).uniq roles.select(&:running_traefik?).flat_map(&:hosts).uniq
end end
def boot
Mrsk::Configuration::Boot.new(config: self)
end
def repository def repository
[ raw_config.registry["server"], image ].compact.join("/") [ raw_config.registry["server"], image ].compact.join("/")
@@ -149,14 +153,18 @@ class Mrsk::Configuration
end end
def ssh_options def ssh_options
{ user: ssh_user, proxy: ssh_proxy, auth_methods: [ "publickey" ] }.compact { user: ssh_user, proxy: ssh_proxy, auth_methods: [ "publickey" ], logger: ssh_logger }.compact
end end
def ssh_logger
def audit_broadcast_cmd @ssh_logger ||= ::Logger.new(STDERR).tap { |logger| logger.level = ssh_log_level }
raw_config.audit_broadcast_cmd
end end
def ssh_log_level
(raw_config.ssh && raw_config.ssh["log_level"]) || ::Logger::FATAL
end
def healthcheck def healthcheck
{ "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {}) { "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
end end
@@ -165,8 +173,12 @@ class Mrsk::Configuration
raw_config.readiness_delay || 7 raw_config.readiness_delay || 7
end end
def minimum_version
raw_config.minimum_version
end
def valid? def valid?
ensure_required_keys_present && ensure_env_available ensure_required_keys_present && ensure_valid_mrsk_version
end end
@@ -181,8 +193,9 @@ class Mrsk::Configuration
service_with_version: service_with_version, service_with_version: service_with_version,
env_args: env_args, env_args: env_args,
volume_args: volume_args, volume_args: volume_args,
ssh_options: ssh_options, ssh_options: ssh_options.except(:logger),
builder: raw_config.builder, ssh_log_level: ssh_log_level,
builder: builder.to_h,
accessories: raw_config.accessories, accessories: raw_config.accessories,
logging: logging_args, logging: logging_args,
healthcheck: healthcheck healthcheck: healthcheck
@@ -193,6 +206,22 @@ class Mrsk::Configuration
raw_config.traefik || {} raw_config.traefik || {}
end end
def hooks_path
raw_config.hooks_path || ".mrsk/hooks"
end
def builder
Mrsk::Configuration::Builder.new(config: self)
end
# Will raise KeyError if any secret ENVs are missing
def ensure_env_available
env_args
roles.each(&:env_args)
true
end
private private
# Will raise ArgumentError if any required config keys are missing # Will raise ArgumentError if any required config keys are missing
def ensure_required_keys_present def ensure_required_keys_present
@@ -217,22 +246,25 @@ class Mrsk::Configuration
true true
end end
# Will raise KeyError if any secret ENVs are missing def ensure_valid_mrsk_version
def ensure_env_available if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Mrsk::VERSION)
env_args raise ArgumentError, "Current version is #{Mrsk::VERSION}, minimum required is #{minimum_version}"
roles.each(&:env_args) end
true true
end end
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 def git_version
@current_commit_hash ||= @git_version ||=
if system("git rev-parse") if system("git rev-parse")
`git rev-parse HEAD`.strip uncommitted_suffix = `git status --porcelain`.strip.present? ? "_uncommitted_#{SecureRandom.hex(8)}" : ""
"#{`git rev-parse HEAD`.strip}#{uncommitted_suffix}"
else else
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}" raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end end

View File

@@ -0,0 +1,20 @@
class Mrsk::Configuration::Boot
def initialize(config:)
@options = config.raw_config.boot || {}
@host_count = config.all_hosts.count
end
def limit
limit = @options["limit"]
if limit.to_s.end_with?("%")
@host_count * limit.to_i / 100
else
limit
end
end
def wait
@options["wait"]
end
end

View File

@@ -0,0 +1,114 @@
class Mrsk::Configuration::Builder
def initialize(config:)
@options = config.raw_config.builder || {}
@image = config.image
@server = config.registry["server"]
valid?
end
def to_h
@options
end
def multiarch?
@options["multiarch"] != false
end
def local?
!!@options["local"]
end
def remote?
!!@options["remote"]
end
def cached?
!!@options["cache"]
end
def args
@options["args"] || {}
end
def secrets
@options["secrets"] || []
end
def dockerfile
@options["dockerfile"] || "Dockerfile"
end
def context
@options["context"] || "."
end
def local_arch
@options["local"]["arch"] if local?
end
def local_host
@options["local"]["host"] if local?
end
def remote_arch
@options["remote"]["arch"] if remote?
end
def remote_host
@options["remote"]["host"] if remote?
end
def cache_from
if cached?
case @options["cache"]["type"]
when "gha"
cache_from_config_for_gha
when "registry"
cache_from_config_for_registry
end
end
end
def cache_to
if cached?
case @options["cache"]["type"]
when "gha"
cache_to_config_for_gha
when "registry"
cache_to_config_for_registry
end
end
end
private
def valid?
if @options["local"] && !@options["remote"]
raise ArgumentError, "You must specify both local and remote builder config for remote multiarch builds"
end
if @options["cache"] && @options["cache"]["type"]
raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless ["gha", "registry"].include?(@options["cache"]["type"])
end
end
def cache_image
@options["cache"]&.fetch("image", nil) || "#{@image}-build-cache"
end
def cache_from_config_for_gha
"type=gha"
end
def cache_from_config_for_registry
[ "type=registry", "ref=#{@server}/#{cache_image}" ].compact.join(",")
end
def cache_to_config_for_gha
[ "type=gha", @options["cache"]&.fetch("options", nil)].compact.join(",")
end
def cache_to_config_for_registry
[ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{@server}/#{cache_image}" ].compact.join(",")
end
end

View File

@@ -35,6 +35,28 @@ class Mrsk::Configuration::Role
argumentize_env_with_secrets env argumentize_env_with_secrets env
end end
def health_check_args
if health_check_cmd.present?
optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
else
[]
end
end
def health_check_cmd
options = specializations["healthcheck"] || {}
options = config.healthcheck.merge(options) if running_traefik?
options["cmd"] || http_health_check(port: options["port"], path: options["path"])
end
def health_check_interval
options = specializations["healthcheck"] || {}
options = config.healthcheck.merge(options) if running_traefik?
options["interval"] || "1s"
end
def cmd def cmd
specializations["cmd"] specializations["cmd"]
end end
@@ -74,9 +96,10 @@ class Mrsk::Configuration::Role
def traefik_labels def traefik_labels
if running_traefik? if running_traefik?
{ {
# Setting a service property ensures that the generated service name will be consistent between versions
"traefik.http.services.#{traefik_service}.loadbalancer.server.scheme" => "http",
"traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)", "traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)",
"traefik.http.services.#{traefik_service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{traefik_service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5", "traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5",
"traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms", "traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms",
"traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker" "traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker"
@@ -125,4 +148,8 @@ class Mrsk::Configuration::Role
new_env["clear"] = (clear_app_env + clear_role_env).uniq new_env["clear"] = (clear_app_env + clear_role_env).uniq
end end
end end
def http_health_check(port:, path:)
"curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present?
end
end end

View File

@@ -1,12 +1,56 @@
require "sshkit" require "sshkit"
require "sshkit/dsl" require "sshkit/dsl"
require "active_support/core_ext/hash/deep_merge"
require "json"
class SSHKit::Backend::Abstract class SSHKit::Backend::Abstract
def capture_with_info(*args) def capture_with_info(*args, **kwargs)
capture(*args, verbosity: Logger::INFO) capture(*args, **kwargs, verbosity: Logger::INFO)
end
def capture_with_debug(*args, **kwargs)
capture(*args, **kwargs, verbosity: Logger::DEBUG)
end
def capture_with_pretty_json(*args, **kwargs)
JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
end end
def puts_by_host(host, output, type: "App") def puts_by_host(host, output, type: "App")
puts "#{type} Host: #{host}\n#{output}\n\n" puts "#{type} Host: #{host}\n#{output}\n\n"
end end
# Our execution pattern is for the CLI execute args lists returned
# from commands, but this doesn't support returning execution options
# from the command.
#
# Support this by using kwargs for CLI options and merging with the
# args-extracted options.
module CommandEnvMerge
private
# Override to merge options returned by commands in the args list with
# options passed by the CLI and pass them along as kwargs.
def command(args, options)
more_options, args = args.partition { |a| a.is_a? Hash }
more_options << options
build_command(args, **more_options.reduce(:deep_merge))
end
# Destructure options to pluck out env for merge
def build_command(args, env: nil, **options)
# Rely on native Ruby kwargs precedence rather than explicit Hash merges
SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))
end
def default_command_options
{ in: pwd_path, host: @host, user: @user, group: @group }
end
def env_for(env)
@env.to_h.merge(env.to_h)
end
end
prepend CommandEnvMerge
end end

39
lib/mrsk/tags.rb Normal file
View File

@@ -0,0 +1,39 @@
require "time"
class Mrsk::Tags
attr_reader :config, :tags
class << self
def from_config(config, **extra)
new(**default_tags(config), **extra)
end
def default_tags(config)
{ recorded_at: Time.now.utc.iso8601,
performer: `whoami`.chomp,
destination: config.destination,
version: config.version,
service_version: service_version(config) }
end
def service_version(config)
[ config.service, config.abbreviated_version ].compact.join("@")
end
end
def initialize(**tags)
@tags = tags.compact
end
def env
tags.transform_keys { |detail| "MRSK_#{detail.upcase}" }
end
def to_s
tags.values.map { |value| "[#{value}]" }.join(" ")
end
def except(*tags)
self.class.new(**self.tags.except(*tags))
end
end

View File

@@ -1,6 +1,8 @@
module Mrsk::Utils module Mrsk::Utils
extend self extend self
DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\$(?!{[^\}]*\})/
# Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array). # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
def argumentize(argument, attributes, sensitive: false) def argumentize(argument, attributes, sensitive: false)
Array(attributes).flat_map do |key, value| Array(attributes).flat_map do |key, value|
@@ -75,11 +77,20 @@ module Mrsk::Utils
# Escape a value to make it safe for shell use. # Escape a value to make it safe for shell use.
def escape_shell_value(value) def escape_shell_value(value)
value.to_s.dump.gsub(/`/, '\\\\`') value.to_s.dump
.gsub(/`/, '\\\\`')
.gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$')
end end
# Abbreviate a git revhash for concise display # Abbreviate a git revhash for concise display
def abbreviate_version(version) def abbreviate_version(version)
version[0...7] if version if version
# Don't abbreviate <sha>_uncommitted_<etc>
if version.include?("_")
version
else
version[0...7]
end
end
end end
end end

View File

@@ -0,0 +1,39 @@
class Mrsk::Utils::HealthcheckPoller
TRAEFIK_HEALTHY_DELAY = 2
class HealthcheckError < StandardError; end
class << self
def wait_for_healthy(pause_after_ready: false, &block)
attempt = 1
max_attempts = MRSK.config.healthcheck["max_attempts"]
begin
case status = block.call
when "healthy"
sleep TRAEFIK_HEALTHY_DELAY if pause_after_ready
when "running" # No health check configured
sleep MRSK.config.readiness_delay if pause_after_ready
else
raise HealthcheckError, "container not ready (#{status})"
end
rescue HealthcheckError => e
if attempt <= max_attempts
info "#{e.message}, retrying in #{attempt}s (attempt #{attempt}/#{max_attempts})..."
sleep attempt
attempt += 1
retry
else
raise
end
end
info "Container is healthy!"
end
private
def info(message)
SSHKit.config.output.info(message)
end
end
end

View File

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

View File

@@ -40,9 +40,10 @@ class CliAccessoryTest < CliTestCase
end end
test "reboot" do test "reboot" do
Mrsk::Commands::Registry.any_instance.expects(:login)
Mrsk::Cli::Accessory.any_instance.expects(:stop).with("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(:remove_container).with("mysql")
Mrsk::Cli::Accessory.any_instance.expects(:boot).with("mysql") Mrsk::Cli::Accessory.any_instance.expects(:boot).with("mysql", login: false)
run_command("reboot", "mysql") run_command("reboot", "mysql")
end end

View File

@@ -2,11 +2,10 @@ require_relative "cli_test_case"
class CliAppTest < CliTestCase class CliAppTest < CliTestCase
test "boot" do test "boot" do
# Stub current version fetch stub_running
SSHKit::Backend::Abstract.any_instance.stubs(:capture).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 tag dhh/app:latest dhh/app:latest", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-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
@@ -15,23 +14,49 @@ class CliAppTest < CliTestCase
run_command("details") # Preheat MRSK const run_command("details") # Preheat MRSK const
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet") .with(:docker, :container, :ls, "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-", raise_on_non_zero_exit: false)
.returns("123") # old version .returns("123") # old version
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker rename .* .*/, output assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output
assert_match "docker run --detach --restart unless-stopped", output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end end
ensure ensure
Thread.report_on_exception = true Thread.report_on_exception = true
end end
test "boot uses group strategy when specified" do
Mrsk::Cli::App.any_instance.stubs(:on).with("1.1.1.1").twice # acquire & release lock
Mrsk::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1" ]) # tag container
# Strategy is used when booting the containers
Mrsk::Cli::App.any_instance.expects(:on).with([ "1.1.1.1" ], in: :groups, limit: 3, wait: 2).with_block_given
run_command("boot", config: :with_boot_strategy)
end
test "boot errors leave lock in place" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999" }
Mrsk::Cli::App.any_instance.expects(:using_version).raises(RuntimeError)
assert !MRSK.holding_lock?
assert_raises(RuntimeError) do
stderred { run_command("boot") }
end
assert MRSK.holding_lock?
end
test "start" do test "start" do
run_command("start").tap do |output| run_command("start").tap do |output|
assert_match "docker start app-web-999", output assert_match "docker start app-web-999", output
@@ -40,7 +65,28 @@ class CliAppTest < CliTestCase
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 --filter label=role=web | xargs docker stop", output assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop", output
end
end
test "stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-", raise_on_non_zero_exit: false)
.returns("12345678\n87654321")
run_command("stale_containers").tap do |output|
assert_match /Detected stale container for role web with version 87654321/, output
end
end
test "stop stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-", raise_on_non_zero_exit: false)
.returns("12345678\n87654321")
run_command("stale_containers", "--stop").tap do |output|
assert_match /Stopping stale container for role web with version 87654321/, output
assert_match /#{Regexp.escape("docker container ls --all --filter name=^app-web-87654321$ --quiet | xargs docker stop")}/, output
end end
end end
@@ -52,7 +98,7 @@ class CliAppTest < CliTestCase
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop")}/, output assert_match /#{Regexp.escape("docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop")}/, output
assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output
end end
@@ -84,7 +130,7 @@ class CliAppTest < CliTestCase
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 "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 --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-", output # Get current version
assert_match "docker exec app-web-999 ruby -v", output assert_match "docker exec app-web-999 ruby -v", output
end end
end end
@@ -103,33 +149,41 @@ class CliAppTest < CliTestCase
test "logs" do test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) 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'") .with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest| 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") assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1", run_command("logs")
end end
test "logs with follow" do test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) 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'") .with("ssh -t root@1.1.1.1 'docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | 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") assert_match "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow")
end end
test "version" do test "version" do
run_command("version").tap do |output| run_command("version").tap do |output|
assert_match "docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", output assert_match "docker ps --filter label=service=app --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-", output
end end
end end
test "version through main" do 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| 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 assert_match "docker ps --filter label=service=app --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-", output
end end
end end
private private
def run_command(*command) def run_command(*command, config: :with_accessories)
stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1"]) } stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_#{config}.yml", "--hosts", "1.1.1.1"]) }
end
def stub_running
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running") # health check
end end
end end

View File

@@ -9,7 +9,12 @@ class CliBuildTest < CliTestCase
end end
test "push" do test "push" do
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
run_command("push").tap do |output| run_command("push").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables
assert_match /docker --version && docker buildx version/, 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 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
end end
@@ -17,7 +22,10 @@ class CliBuildTest < CliTestCase
test "push without builder" do test "push without builder" do
stub_locking stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg| arg == :docker } .with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [:docker, :buildx] }
.raises(SSHKit::Command::Failed.new("no builder")) .raises(SSHKit::Command::Failed.new("no builder"))
.then .then
.returns(true) .returns(true)
@@ -27,10 +35,28 @@ class CliBuildTest < CliTestCase
end end
end end
test "push with no buildx plugin" do
stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with(:docker, "--version", "&&", :docker, :buildx, "version")
.raises(SSHKit::Command::Failed.new("no buildx"))
Mrsk::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false)
assert_raises(Mrsk::Cli::Build::BuildError) { run_command("push") }
end
test "push pre-build hook failure" do
fail_hook("pre-build")
assert_raises(Mrsk::Cli::HookError) { run_command("push") }
assert @executions.none? { |args| args[0..2] == [:docker, :buildx, :build] }
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:latest/, output assert_match /docker pull dhh\/app:999/, output
end end
end end
@@ -73,10 +99,10 @@ class CliBuildTest < CliTestCase
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 def stub_dependency_checks
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :mkdir && arg2 == :mrsk_lock } .with(:docker, "--version", "&&", :docker, :buildx, "version")
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == "mrsk_lock/details" } .with { |*args| args[0..1] == [:docker, :buildx] }
end end
end end

View File

@@ -1,9 +1,6 @@
require "test_helper" require "test_helper"
require "active_support/testing/stream"
class CliTestCase < ActiveSupport::TestCase class CliTestCase < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do setup do
ENV["VERSION"] = "999" ENV["VERSION"] = "999"
ENV["RAILS_MASTER_KEY"] = "123" ENV["RAILS_MASTER_KEY"] = "123"
@@ -19,11 +16,41 @@ class CliTestCase < ActiveSupport::TestCase
end end
private private
def stdouted def fail_hook(hook)
capture(:stdout) { yield }.strip @executions = []
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| @executions << args; args != [".mrsk/hooks/#{hook}"] }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args.first == ".mrsk/hooks/#{hook}" }
.raises(SSHKit::Command::Failed.new("failed"))
end end
def stderred def stub_locking
capture(:stderr) { yield }.strip SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :mkdir && arg2 == "mrsk_lock-app" }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == "mrsk_lock-app/details" }
end end
end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: nil)
performer = `whoami`.strip
assert_match "Running the #{hook} hook...\n", output
expected = %r{Running\s/usr/bin/env\s\.mrsk/hooks/#{hook}\sas\s#{performer}@localhost\n\s
DEBUG\s\[[0-9a-f]*\]\sCommand:\s\(\sexport\s
MRSK_RECORDED_AT=\"\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ\"\s
MRSK_PERFORMER=\"#{performer}\"\s
MRSK_VERSION=\"#{version}\"\s
MRSK_SERVICE_VERSION=\"#{service_version}\"\s
MRSK_HOSTS=\"#{hosts}\"\s
MRSK_COMMAND=\"#{command}\"\s
#{"MRSK_SUBCOMMAND=\\\"#{subcommand}\\\"\\s" if subcommand}
#{"MRSK_RUNTIME=\\\"#{runtime}\\\"\\s" if runtime}
;\s/usr/bin/env\s\.mrsk/hooks/#{hook} }x
assert_match expected, output
end
end

View File

@@ -5,62 +5,63 @@ class CliHealthcheckTest < CliTestCase
# Prevent expected failures from outputting to terminal # Prevent expected failures from outputting to terminal
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:sleep) # No sleeping when retrying Mrsk::Utils::HealthcheckPoller.stubs(:sleep) # No sleeping when retrying
SSHKit::Backend::Abstract.any_instance.stubs(:execute) 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) .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) 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") .with(:docker, :run, "--detach", "--name", "healthcheck-app-999", "--publish", "3999:3000", "--label", "service=healthcheck-app", "-e", "MRSK_CONTAINER_NAME=\"healthcheck-app\"", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "dhh/app:999")
SSHKit::Backend::Abstract.any_instance.stubs(:execute) 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) .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 # Fail twice to test retry logic
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) 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") .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.raises(SSHKit::Command::Failed) .returns("starting")
.then .then
.raises(SSHKit::Command::Failed) .returns("unhealthy")
.then .then
.returns("200") .returns("healthy")
run_command("perform").tap do |output| run_command("perform").tap do |output|
assert_match "Health check against /up failed to respond, retrying in 1s (attempt 1/7)...", output assert_match "container not ready (starting), retrying in 1s (attempt 1/7)...", output
assert_match "Health check against /up failed to respond, retrying in 2s (attempt 2/7)...", output assert_match "container not ready (unhealthy), retrying in 2s (attempt 2/7)...", output
assert_match "Health check against /up succeeded with 200 OK!", output assert_match "Container is healthy!", output
end end
end end
test "perform failing because of curl" do test "perform failing to become healthy" do
# Prevent expected failures from outputting to terminal # Prevent expected failures from outputting to terminal
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute) # No need to execute anything here Mrsk::Utils::HealthcheckPoller.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\"", "--health-cmd", "\"curl -f http://localhost:3000/up || exit 1\"", "--health-interval", "\"1s\"", "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)
# Continually report unhealthy
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) 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") .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("curl: command not found") .returns("unhealthy")
# Capture logs when failing
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info) 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") .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1")
.returns("some log output")
exception = assert_raises SSHKit::Runner::ExecuteError do # Capture container health log when failing
run_command("perform") SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_pretty_json)
end .with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{json .State.Health}}'")
assert_match "Health check against /up failed to return 200 OK!", exception.message .returns('{"Status":"unhealthy","Log":[{"ExitCode": 1,"Output": "/bin/sh: 1: curl: not found\n"}]}"')
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 exception = assert_raises do
run_command("perform") run_command("perform")
end end
assert_match "Health check against /up failed with status 500", exception.message assert_match "container not ready (unhealthy)", exception.message
end end
private private

View File

@@ -10,44 +10,50 @@ class CliMainTest < CliTestCase
end end
test "deploy" do test "deploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
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: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: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: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:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:stale_containers", [], 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:app:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options)
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy").tap do |output| run_command("deploy").tap do |output|
assert_match /Ensure curl and Docker are installed/, output assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: 0
end end
end end
test "deploy with skip_push" do test "deploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
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: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: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: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:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:stale_containers", [], 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:app:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options) Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:prune:all", [], invoke_options)
run_command("deploy", "--skip_push").tap do |output| run_command("deploy", "--skip_push").tap do |output|
assert_match /Acquiring the deploy lock/, 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 /Log into image registry/, output
assert_match /Pull app image/, output assert_match /Pull app image/, output
assert_match /Ensure Traefik is running/, output assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_match /Releasing the deploy lock/, output assert_match /Releasing the deploy lock/, output
end end
@@ -57,10 +63,11 @@ class CliMainTest < CliTestCase
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [:mkdir, :mrsk_lock] } .with { |*arg| arg[0..1] == [:mkdir, 'mrsk_lock-app'] }
.raises(RuntimeError, "mkdir: cannot create directory mrsk_lock: File exists") .raises(RuntimeError, "mkdir: cannot create directory mrsk_lock-app: File exists")
Mrsk::Cli::Base.any_instance.expects(:invoke).with("mrsk:cli:lock:status", []) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug)
.with(:stat, 'mrsk_lock-app', ">", "/dev/null", "&&", :cat, "mrsk_lock-app/details", "|", :base64, "-d")
assert_raises(Mrsk::Cli::LockError) do assert_raises(Mrsk::Cli::LockError) do
run_command("deploy") run_command("deploy")
@@ -71,7 +78,7 @@ class CliMainTest < CliTestCase
Thread.report_on_exception = false Thread.report_on_exception = false
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*arg| arg[0..1] == [:mkdir, :mrsk_lock] } .with { |*arg| arg[0..1] == [:mkdir, 'mrsk_lock-app'] }
.raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known") .raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known")
assert_raises(SSHKit::Runner::ExecuteError) do assert_raises(SSHKit::Runner::ExecuteError) do
@@ -79,38 +86,70 @@ class CliMainTest < CliTestCase
end end
end end
test "deploy errors leave lock in place" do test "deploy errors during outside section leave remove lock" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Mrsk::Cli::Main.any_instance.expects(:invoke) Mrsk::Cli::Main.any_instance.expects(:invoke)
.with("mrsk:cli:server:bootstrap", [], invoke_options) .with("mrsk:cli:registry:login", [], invoke_options)
.raises(RuntimeError) .raises(RuntimeError)
assert_equal 0, MRSK.lock_count assert !MRSK.holding_lock?
assert_raises(RuntimeError) do assert_raises(RuntimeError) do
stderred { run_command("deploy") } stderred { run_command("deploy") }
end end
assert_equal 1, MRSK.lock_count assert !MRSK.holding_lock?
end
test "deploy with skipped hooks" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => true }
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:stale_containers", [], 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_hooks") do
refute_match /Running the post-deploy hook.../, output
end
end
test "deploy with missing secrets" do
assert_raises(KeyError) do
run_command("deploy", config_file: "deploy_with_secrets")
end
end end
test "redeploy" do test "redeploy" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], 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:healthcheck:perform", [], 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:stale_containers", [], 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:app:boot", [], invoke_options)
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" }
run_command("redeploy").tap do |output| run_command("redeploy").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables
assert_match /Running the pre-deploy hook.../, output
assert_match /Ensure app can pass healthcheck/, output assert_match /Ensure app can pass healthcheck/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0"
end end
end end
test "redeploy with skip_push" do test "redeploy with skip_push" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], 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:healthcheck:perform", [], 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:stale_containers", [], 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:app:boot", [], invoke_options)
run_command("redeploy", "--skip_push").tap do |output| run_command("redeploy", "--skip_push").tap do |output|
@@ -120,7 +159,8 @@ class CliMainTest < CliTestCase
end end
test "rollback bad version" do test "rollback bad version" do
# Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(false) Thread.report_on_exception = false
run_command("details") # Preheat MRSK const run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output| run_command("rollback", "nonsense").tap do |output|
@@ -130,24 +170,52 @@ class CliMainTest < CliTestCase
end end
test "rollback good version" do test "rollback good version" do
Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true) [ "web", "workers" ].each do |role|
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=workers", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("version-to-rollback\n").at_least_once .with(:docker, :container, :ls, "--filter", "name=^app-#{role}-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet")
.returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=#{role}", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-", raise_on_non_zero_exit: false)
.returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running").at_least_once # health check
end
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" }
run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output| run_command("rollback", "123", config_file: "deploy_with_accessories").tap do |output|
assert_match "Start version 123", output assert_match "Start container with version 123", output
assert_hook_ran "pre-deploy", output, **hook_variables
assert_match "docker tag dhh/app:123 dhh/app:latest", output
assert_match "docker start app-web-123", output assert_match "docker start app-web-123", output
assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running" assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
assert_hook_ran "post-deploy", output, **hook_variables, runtime: "0"
end end
end end
test "rollback without old version" do test "rollback without old version" do
Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true) Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "sed 's/-/\\n/g'", "|", "tail -n 1").returns("").at_least_once
Mrsk::Utils::HealthcheckPoller.stubs(:sleep)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--filter", "name=^app-web-123$", "--quiet", raise_on_non_zero_exit: false)
.returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--filter", "status=restarting", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-", raise_on_non_zero_exit: false)
.returns("").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'")
.returns("running").at_least_once # health check
run_command("rollback", "123").tap do |output| run_command("rollback", "123").tap do |output|
assert_match "Start version 123", output assert_match "Start container with version 123", output
assert_match "docker start app-web-123", output assert_match "docker start app-web-123 || docker run --detach --restart unless-stopped --name app-web-123", output
assert_no_match "docker stop", output assert_no_match "docker stop", output
end end
end end
@@ -207,9 +275,11 @@ class CliMainTest < CliTestCase
end end
test "init" do test "init" do
Pathname.any_instance.expects(:exist?).returns(false).twice Pathname.any_instance.expects(:exist?).returns(false).times(3)
Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
FileUtils.stubs(:cp)
run_command("init").tap do |output| run_command("init").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output assert_match /Created configuration file in config\/deploy.yml/, output
@@ -218,7 +288,7 @@ class CliMainTest < CliTestCase
end end
test "init with existing config" do test "init with existing config" do
Pathname.any_instance.expects(:exist?).returns(true).twice Pathname.any_instance.expects(:exist?).returns(true).times(3)
run_command("init").tap do |output| run_command("init").tap do |output|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
@@ -226,9 +296,11 @@ class CliMainTest < CliTestCase
end end
test "init with bundle option" do test "init with bundle option" do
Pathname.any_instance.expects(:exist?).returns(false).times(3) Pathname.any_instance.expects(:exist?).returns(false).times(4)
Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
FileUtils.stubs(:cp)
run_command("init", "--bundle").tap do |output| run_command("init", "--bundle").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output assert_match /Created configuration file in config\/deploy.yml/, output
@@ -241,9 +313,11 @@ class CliMainTest < CliTestCase
end end
test "init with bundle option and existing binstub" do test "init with bundle option and existing binstub" do
Pathname.any_instance.expects(:exist?).returns(true).times(3) Pathname.any_instance.expects(:exist?).returns(true).times(4)
Pathname.any_instance.stubs(:mkpath)
FileUtils.stubs(:mkdir_p) FileUtils.stubs(:mkdir_p)
FileUtils.stubs(:cp_r) FileUtils.stubs(:cp_r)
FileUtils.stubs(:cp)
run_command("init", "--bundle").tap do |output| 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 /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output

View File

@@ -10,13 +10,14 @@ class CliPruneTest < CliTestCase
test "images" do test "images" do
run_command("images").tap do |output| 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 assert_match "docker image prune --force --filter label=service=app --filter dangling=true on 1.1.1.", output
assert_match "docker image ls --filter label=service=app --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v -w \"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=app | tr -d '\\n')dhh/app:latest\\|dhh/app:<none>\" | while read image tag; do docker rmi $tag; done on 1.1.1.", output
end end
end end
test "containers" do test "containers" do
run_command("containers").tap do |output| 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 assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output
end end
end end

View File

@@ -1,11 +1,30 @@
require_relative "cli_test_case" require_relative "cli_test_case"
class CliServerTest < CliTestCase class CliServerTest < CliTestCase
test "bootstrap" do test "bootstrap already installed" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once
assert_equal "", run_command("bootstrap")
end
test "bootstrap install as non-root user" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(false).at_least_once
assert_raise RuntimeError, "Docker is not installed on 1.1.1.1, 1.1.1.3, 1.1.1.4, 1.1.1.2 and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/" do
run_command("bootstrap")
end
end
test "bootstrap install as root user" do
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(true).at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:curl, "-fsSL", "https://get.docker.com", "|", :sh).at_least_once
run_command("bootstrap").tap do |output| run_command("bootstrap").tap do |output|
assert_match /which curl/, output ("1.1.1.1".."1.1.1.4").map do |host|
assert_match /which docker/, output assert_match "Missing Docker on #{host}. Installing…", output
assert_match /apt-get update -y && apt-get install curl docker.io -y/, output end
end end
end end

View File

@@ -4,16 +4,24 @@ class CliTraefikTest < CliTestCase
test "boot" do test "boot" do
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match "docker login", output assert_match "docker login", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=DEBUG", output assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output
end end
end end
test "reboot" do test "reboot" do
Mrsk::Cli::Traefik.any_instance.expects(:stop) Mrsk::Commands::Registry.any_instance.expects(:login).twice
Mrsk::Cli::Traefik.any_instance.expects(:remove_container)
Mrsk::Cli::Traefik.any_instance.expects(:boot)
run_command("reboot") run_command("reboot").tap do |output|
assert_match "docker container stop traefik", output
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=Traefik", output
assert_match "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"", output
end
end
test "reboot --rolling" do
run_command("reboot", "--rolling").tap do |output|
assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=Traefik on 1.1.1.1", output.lines[3]
end
end end
test "start" do test "start" do

View File

@@ -2,9 +2,7 @@ require "test_helper"
class CommanderTest < ActiveSupport::TestCase class CommanderTest < ActiveSupport::TestCase
setup do setup do
@mrsk = Mrsk::Commander.new.tap do |mrsk| configure_with(:deploy_with_roles)
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
@@ -47,12 +45,35 @@ class CommanderTest < ActiveSupport::TestCase
end end
test "primary_host with specific hosts via role" do test "primary_host with specific hosts via role" do
@mrsk.specific_roles = "web" @mrsk.specific_roles = "workers"
assert_equal "1.1.1.1", @mrsk.primary_host assert_equal "1.1.1.3", @mrsk.primary_host
end end
test "roles_on" do test "roles_on" do
assert_equal [ "web" ], @mrsk.roles_on("1.1.1.1") assert_equal [ "web" ], @mrsk.roles_on("1.1.1.1")
assert_equal [ "workers" ], @mrsk.roles_on("1.1.1.3") assert_equal [ "workers" ], @mrsk.roles_on("1.1.1.3")
end end
test "default group strategy" do
assert_empty @mrsk.boot_strategy
end
test "specific limit group strategy" do
configure_with(:deploy_with_boot_strategy)
assert_equal({ in: :groups, limit: 3, wait: 2 }, @mrsk.boot_strategy)
end
test "percentage-based group strategy" do
configure_with(:deploy_with_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @mrsk.boot_strategy)
end
private
def configure_with(variant)
@mrsk = Mrsk::Commander.new.tap do |mrsk|
mrsk.configure config_file: Pathname.new(File.expand_path("fixtures/#{variant}.yml", __dir__))
end
end
end end

View File

@@ -13,15 +13,21 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with hostname" do
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run(hostname: "myhost").join(" ")
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 --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -29,7 +35,23 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:healthcheck] = { "path" => "/healthz" } @config[:healthcheck] = { "path" => "/healthz" }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/healthz\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/healthz || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ")
end
test "run with custom healthcheck command" do
@config[:healthcheck] = { "cmd" => "/bin/up" }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"/bin/up\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ")
end
test "run with role-specific healthcheck options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "healthcheck" => { "cmd" => "/bin/healthy" } } }
assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"/bin/healthy\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -44,7 +66,7 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999", "docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -61,16 +83,28 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.start.join(" ") new_command.start.join(" ")
end end
test "start_or_run" do
assert_equal \
"docker start app-web-999 || docker run --detach --restart unless-stopped --name app-web-999 -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.start_or_run.join(" ")
end
test "start_or_run with hostname" do
assert_equal \
"docker start app-web-999 || docker run --detach --restart unless-stopped --name app-web-999 --hostname myhost -e MRSK_CONTAINER_NAME=\"app-web-999\" -e RAILS_MASTER_KEY=\"456\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label traefik.http.services.app-web.loadbalancer.server.scheme=\"http\" --label traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.middlewares.app-web-retry.retry.attempts=\"5\" --label traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\" --label traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\" dhh/app:999",
new_command.start_or_run(hostname: "myhost").join(" ")
end
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
test "stop with custom stop wait time" do test "stop with custom stop wait time" do
@config[:stop_wait_time] = 30 @config[:stop_wait_time] = 30
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker stop -t 30", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker stop -t 30",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
@@ -96,37 +130,37 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1",
new_command.logs(since: "5m").join(" ") new_command.logs(since: "5m").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --tail 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --tail 100 2>&1",
new_command.logs(lines: "100").join(" ") new_command.logs(lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m --tail 100 2>&1", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m --tail 100 2>&1",
new_command.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 --filter label=role=web | xargs docker logs 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs 2>&1 | grep 'my-id'",
new_command.logs(grep: "my-id").join(" ") new_command.logs(grep: "my-id").join(" ")
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --since 5m 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --since 5m 2>&1 | grep 'my-id'",
new_command.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
assert_match \ assert_match \
"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 --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1",
new_command.follow_logs(host: "app-1") new_command.follow_logs(host: "app-1")
assert_match \ assert_match \
"docker ps --quiet --filter label=service=app --filter label=role=web | xargs docker logs --timestamps --tail 10 --follow 2>&1 | grep \"Completed\"", "docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest | xargs docker logs --timestamps --tail 10 --follow 2>&1 | grep \"Completed\"",
new_command.follow_logs(host: "app-1", grep: "Completed") new_command.follow_logs(host: "app-1", grep: "Completed")
end end
@@ -177,18 +211,22 @@ class CommandsAppTest < ActiveSupport::TestCase
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") 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 "run over ssh with proxy_command" do
test "current_container_id" do @config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" }
assert_equal \ assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
"docker ps --quiet --filter label=service=app --filter label=role=web",
new_command.current_container_id.join(" ")
end end
test "current_container_id with destination" do test "current_running_container_id" do
assert_equal \
"docker ps --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest",
new_command.current_running_container_id.join(" ")
end
test "current_running_container_id with destination" do
@destination = "staging" @destination = "staging"
assert_equal \ assert_equal \
"docker ps --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web", "docker ps --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --latest",
new_command.current_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
test "container_id_for" do test "container_id_for" do
@@ -199,10 +237,20 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_version" do test "current_running_version" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app --filter label=role=web --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1", "docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-",
new_command.current_running_version.join(" ") new_command.current_running_version.join(" ")
end end
test "list_versions" do
assert_equal \
"docker ps --filter label=service=app --filter label=role=web --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-",
new_command.list_versions.join(" ")
assert_equal \
"docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | grep -oE \"\\-[^-]+$\" | cut -c 2-",
new_command.list_versions("--latest", statuses: [ :running, :restarting ]).join(" ")
end
test "list_containers" do test "list_containers" do
assert_equal \ assert_equal \
"docker container ls --all --filter label=service=app --filter label=role=web", "docker container ls --all --filter label=service=app --filter label=role=web",
@@ -267,6 +315,12 @@ class CommandsAppTest < ActiveSupport::TestCase
new_command.remove_images.join(" ") new_command.remove_images.join(" ")
end end
test "tag_current_as_latest" do
assert_equal \
"docker tag dhh/app:999 dhh/app:latest",
new_command.tag_current_as_latest.join(" ")
end
private private
def new_command(role: "web") def new_command(role: "web")
Mrsk::Commands::App.new(Mrsk::Configuration.new(@config, destination: @destination, version: "999"), role: role) Mrsk::Commands::App.new(Mrsk::Configuration.new(@config, destination: @destination, version: "999"), role: role)

View File

@@ -1,43 +1,64 @@
require "test_helper" require "test_helper"
require "active_support/testing/time_helpers"
class CommandsAuditorTest < ActiveSupport::TestCase class CommandsAuditorTest < ActiveSupport::TestCase
include ActiveSupport::Testing::TimeHelpers
setup do setup do
freeze_time
@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" ]
audit_broadcast_cmd: "bin/audit_broadcast"
} }
@auditor = new_command
@performer = `whoami`.strip
@recorded_at = Time.now.utc.iso8601
end end
test "record" do test "record" do
assert_match \ assert_equal [
/echo '.* app removed container' >> mrsk-app-audit.log/, :echo,
new_command.record("app removed container").join(" ") "[#{@recorded_at}] [#{@performer}]",
"app removed container",
">>", "mrsk-app-audit.log"
], @auditor.record("app removed container")
end end
test "record with destination" do test "record with destination" do
@destination = "staging" new_command(destination: "staging").tap do |auditor|
assert_equal [
assert_match \ :echo,
/echo '.* app removed container' >> mrsk-app-staging-audit.log/, "[#{@recorded_at}] [#{@performer}] [staging]",
new_command.record("app removed container").join(" ") "app removed container",
">>", "mrsk-app-staging-audit.log"
], auditor.record("app removed container")
end
end end
test "record with role" do test "record with command details" do
@role = "web" new_command(role: "web").tap do |auditor|
assert_equal [
assert_match \ :echo,
/echo '.* \[web\] app removed container' >> mrsk-app-audit.log/, "[#{@recorded_at}] [#{@performer}] [web]",
new_command.record("app removed container").join(" ") "app removed container",
">>", "mrsk-app-audit.log"
], auditor.record("app removed container")
end
end end
test "broadcast" do test "record with arg details" do
assert_match \ assert_equal [
/bin\/audit_broadcast '\[.*\] app removed container'/, :echo,
new_command.broadcast("app removed container").join(" ") "[#{@recorded_at}] [#{@performer}] [value]",
"app removed container",
">>", "mrsk-app-audit.log"
], @auditor.record("app removed container", detail: "value")
end end
private private
def new_command def new_command(destination: nil, **details)
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"), role: @role) Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: destination, version: "123"), **details)
end end
end end

View File

@@ -6,10 +6,10 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "target multiarch by default" do test "target multiarch by default" do
builder = new_builder_command builder = new_builder_command(builder: { "cache" => { "type" => "gha" }})
assert_equal "multiarch", builder.name assert_equal "multiarch", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -21,19 +21,27 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder.push.join(" ") builder.push.join(" ")
end end
test "target native cached when multiarch is off and cache is set" do
builder = new_builder_command(builder: { "multiarch" => false, "cache" => { "type" => "gha" }})
assert_equal "native/cached", builder.name
assert_equal \
"docker buildx build --push -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
end
test "target multiarch remote when local and remote is set" do test "target multiarch remote when local and remote is set" do
builder = new_builder_command(builder: { "local" => { }, "remote" => { } }) builder = new_builder_command(builder: { "local" => { }, "remote" => { }, "cache" => { "type" => "gha" } })
assert_equal "multiarch/remote", builder.name assert_equal "multiarch/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "target native remote when only remote is set" do test "target native remote when only remote is set" do
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } }) builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" }, "cache" => { "type" => "gha" } })
assert_equal "native/remote", builder.name assert_equal "native/remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -52,12 +60,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase
end end
test "build dockerfile" do test "build dockerfile" do
Pathname.any_instance.expects(:exist?).returns(true).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" }) builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz", "-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
test "missing dockerfile" do
Pathname.any_instance.expects(:exist?).returns(false).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_raises(Mrsk::Commands::Builder::Base::BuilderError) do
builder.target.build_options.join(" ")
end
end
test "build context" do test "build context" do
builder = new_builder_command(builder: { "context" => ".." }) builder = new_builder_command(builder: { "context" => ".." })
assert_equal \ assert_equal \
@@ -79,7 +96,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder.push.join(" ") builder.push.join(" ")
end end
test "native push with with build secrets" do test "native push 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 push dhh/app:latest", "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",

View File

@@ -0,0 +1,26 @@
require "test_helper"
class CommandsDockerTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ]
}
@docker = Mrsk::Commands::Docker.new(Mrsk::Configuration.new(@config))
end
test "install" do
assert_equal "curl -fsSL https://get.docker.com | sh", @docker.install.join(" ")
end
test "installed?" do
assert_equal "docker -v", @docker.installed?.join(" ")
end
test "running?" do
assert_equal "docker version", @docker.running?.join(" ")
end
test "superuser?" do
assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ]', @docker.superuser?.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 -e MRSK_CONTAINER_NAME=\"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\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -18,7 +18,7 @@ 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 -e MRSK_CONTAINER_NAME=\"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\" --health-cmd \"curl -f http://localhost:3001/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -26,29 +26,35 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
@destination = "staging" @destination = "staging"
assert_equal \ 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", "docker run --detach --name healthcheck-app-staging-123 --publish 3999:3000 --label service=healthcheck-app-staging -e MRSK_CONTAINER_NAME=\"healthcheck-app-staging\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ")
end
test "run with custom healthcheck" do
@config[:healthcheck] = { "cmd" => "/bin/up" }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" --health-cmd \"/bin/up\" --health-interval \"1s\" dhh/app:123",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with custom options" do test "run with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere" } } }
assert_equal \ 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", "docker run --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app -e MRSK_CONTAINER_NAME=\"healthcheck-app\" --health-cmd \"curl -f http://localhost:3000/up || exit 1\" --health-interval \"1s\" --mount \"somewhere\" dhh/app:123",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "curl" do test "status" do
assert_equal \ assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/up", "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'",
new_command.curl.join(" ") new_command.status.join(" ")
end end
test "curl with custom path" do test "container_health_log" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \ assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/healthz", "docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{json .State.Health}}'",
new_command.curl.join(" ") new_command.container_health_log.join(" ")
end end
test "stop" do test "stop" do

View File

@@ -0,0 +1,44 @@
require "test_helper"
class CommandsHookTest < ActiveSupport::TestCase
include ActiveSupport::Testing::TimeHelpers
setup do
freeze_time
@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" } }
}
@performer = `whoami`.strip
@recorded_at = Time.now.utc.iso8601
end
test "run" do
assert_equal [
".mrsk/hooks/foo",
{ env: {
"MRSK_RECORDED_AT" => @recorded_at,
"MRSK_PERFORMER" => @performer,
"MRSK_VERSION" => "123",
"MRSK_SERVICE_VERSION" => "app@123" } }
], new_command.run("foo")
end
test "run with custom hooks_path" do
assert_equal [
"custom/hooks/path/foo",
{ env: {
"MRSK_RECORDED_AT" => @recorded_at,
"MRSK_PERFORMER" => @performer,
"MRSK_VERSION" => "123",
"MRSK_SERVICE_VERSION" => "app@123" } }
], new_command(hooks_path: "custom/hooks/path").run("foo")
end
private
def new_command(**extra_config)
Mrsk::Commands::Hook.new(Mrsk::Configuration.new(@config.merge(**extra_config), version: "123"))
end
end

View File

@@ -10,19 +10,19 @@ class CommandsLockTest < ActiveSupport::TestCase
test "status" do test "status" do
assert_equal \ assert_equal \
"stat mrsk_lock > /dev/null && cat mrsk_lock/details | base64 -d", "stat mrsk_lock-app > /dev/null && cat mrsk_lock-app/details | base64 -d",
new_command.status.join(" ") new_command.status.join(" ")
end end
test "acquire" do test "acquire" do
assert_match \ assert_match \
/mkdir mrsk_lock && echo ".*" > mrsk_lock\/details/m, /mkdir mrsk_lock-app && echo ".*" > mrsk_lock-app\/details/m,
new_command.acquire("Hello", "123").join(" ") new_command.acquire("Hello", "123").join(" ")
end end
test "release" do test "release" do
assert_match \ assert_match \
"rm mrsk_lock/details && rm -r mrsk_lock", "rm mrsk_lock-app/details && rm -r mrsk_lock-app",
new_command.release.join(" ") new_command.release.join(" ")
end end

View File

@@ -8,15 +8,21 @@ class CommandsPruneTest < ActiveSupport::TestCase
} }
end end
test "images" do test "dangling images" do
assert_equal \ assert_equal \
"docker image prune --all --force --filter label=service=app --filter until=168h", "docker image prune --force --filter label=service=app --filter dangling=true",
new_command.images.join(" ") new_command.dangling_images.join(" ")
end
test "tagged images" do
assert_equal \
"docker image ls --filter label=service=app --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v -w \"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=app | tr -d '\\n')dhh/app:latest\\|dhh/app:<none>\" | while read image tag; do docker rmi $tag; done",
new_command.tagged_images.join(" ")
end end
test "containers" do test "containers" do
assert_equal \ assert_equal \
"docker container prune --force --filter label=service=app --filter until=72h", "docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done",
new_command.containers.join(" ") new_command.containers.join(" ")
end end

View File

@@ -8,60 +8,77 @@ class CommandsTraefikTest < ActiveSupport::TestCase
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: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } } traefik: { "image" => @image, "args" => { "accesslog.format" => "json", "api.insecure" => true, "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
} }
ENV["EXAMPLE_API_KEY"] = "456"
end
teardown do
ENV.delete("EXAMPLE_API_KEY")
end end
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") 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 --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 8080:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with ports configured" do test "run with ports configured" do
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["options"] = {"publish" => %w[9000:9000 9001:9001]} @config[:traefik]["options"] = {"publish" => %w[9000:9000 9001:9001]}
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --publish \"9000:9000\" --publish \"9001:9001\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --publish \"9000:9000\" --publish \"9001:9001\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with volumes configured" do test "run with volumes configured" do
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] } @config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json] }
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with several options configured" do test "run with several options configured" do
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"} @config[:traefik]["options"] = {"volume" => %w[./letsencrypt/acme.json:/letsencrypt/acme.json], "publish" => %w[8080:8080], "memory" => "512m"}
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --volume \"./letsencrypt/acme.json:/letsencrypt/acme.json\" --publish \"8080:8080\" --memory \"512m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
test "run with labels configured" do test "run with labels configured" do
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
@config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" } @config[:traefik]["labels"] = { "traefik.http.routers.dashboard.service" => "api@internal", "traefik.http.routers.dashboard.middlewares" => "auth" }
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" --label traefik.http.routers.dashboard.service=\"api@internal\" --label traefik.http.routers.dashboard.middlewares=\"auth\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with env configured" do
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
@config[:traefik]["env"] = { "secret" => %w[EXAMPLE_API_KEY] }
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock -e EXAMPLE_API_KEY=\"456\" --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -69,7 +86,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase
@config.delete(:traefik) @config.delete(:traefik)
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=DEBUG", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{Mrsk::Commands::Traefik::DEFAULT_IMAGE} --providers.docker --log.level=\"DEBUG\"",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -77,7 +94,15 @@ class CommandsTraefikTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{@image} --providers.docker --log.level=DEBUG --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"", "docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" #{@image} --providers.docker --log.level=\"DEBUG\" --accesslog.format=\"json\" --api.insecure --metrics.prometheus.buckets=\"0.1,0.3,1.2,5.0\"",
new_command.run.join(" ")
end
test "run with default args overriden" do
@config[:traefik]["args"]["log.level"] = "ERROR"
assert_equal \
"docker run --name traefik --detach --restart unless-stopped --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock --log-opt max-size=\"10m\" #{@image} --providers.docker --log.level=\"ERROR\" --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

View File

@@ -0,0 +1,151 @@
require "test_helper"
class ConfigurationBuilderTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_builder_option = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ],
builder: {}
}
@config_with_builder_option = Mrsk::Configuration.new(@deploy_with_builder_option)
end
test "multiarch?" do
assert_equal true, @config.builder.multiarch?
end
test "setting multiarch to false" do
@deploy_with_builder_option[:builder] = { "multiarch" => false }
assert_equal false, @config_with_builder_option.builder.multiarch?
end
test "local?" do
assert_equal false, @config.builder.local?
end
test "remote?" do
assert_equal false, @config.builder.remote?
end
test "remote_arch" do
assert_nil @config.builder.remote_arch
end
test "remote_host" do
assert_nil @config.builder.remote_host
end
test "remote config is missing when local is specified" do
@deploy_with_builder_option[:builder] = { "local" => { "arch" => "arm64", "host" => "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock" } }
assert_raises(ArgumentError) do
@config_with_builder_option.builder
end
end
test "setting both local and remote configs" do
@deploy_with_builder_option[:builder] = {
"local" => { "arch" => "arm64", "host" => "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock" },
"remote" => { "arch" => "amd64", "host" => "ssh://root@192.168.0.1" }
}
assert_equal true, @config_with_builder_option.builder.local?
assert_equal true, @config_with_builder_option.builder.remote?
assert_equal "amd64", @config_with_builder_option.builder.remote_arch
assert_equal "ssh://root@192.168.0.1", @config_with_builder_option.builder.remote_host
assert_equal "arm64", @config_with_builder_option.builder.local_arch
assert_equal "unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock", @config_with_builder_option.builder.local_host
end
test "cached?" do
assert_equal false, @config.builder.cached?
end
test "invalid cache type specified" do
@deploy_with_builder_option[:builder] = { "cache" => { "type" => "invalid" } }
assert_raises(ArgumentError) do
@config_with_builder_option.builder
end
end
test "cache_from" do
assert_nil @config.builder.cache_from
end
test "cache_to" do
assert_nil @config.builder.cache_to
end
test "setting gha cache" do
@deploy_with_builder_option[:builder] = { "cache" => { "type" => "gha", "options" => "mode=max" } }
assert_equal "type=gha", @config_with_builder_option.builder.cache_from
assert_equal "type=gha,mode=max", @config_with_builder_option.builder.cache_to
end
test "setting registry cache" do
@deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } }
assert_equal "type=registry,ref=/dhh/app-build-cache", @config_with_builder_option.builder.cache_from
assert_equal "type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=/dhh/app-build-cache", @config_with_builder_option.builder.cache_to
end
test "setting registry cache with image" do
@deploy_with_builder_option[:builder] = { "cache" => { "type" => "registry", "image" => "mrsk", "options" => "mode=max" } }
assert_equal "type=registry,ref=/mrsk", @config_with_builder_option.builder.cache_from
assert_equal "type=registry,mode=max,ref=/mrsk", @config_with_builder_option.builder.cache_to
end
test "args" do
assert_equal({}, @config.builder.args)
end
test "setting args" do
@deploy_with_builder_option[:builder] = { "args" => { "key" => "value" } }
assert_equal({ "key" => "value" }, @config_with_builder_option.builder.args)
end
test "secrets" do
assert_equal [], @config.builder.secrets
end
test "setting secrets" do
@deploy_with_builder_option[:builder] = { "secrets" => ["GITHUB_TOKEN"] }
assert_equal ["GITHUB_TOKEN"], @config_with_builder_option.builder.secrets
end
test "dockerfile" do
assert_equal "Dockerfile", @config.builder.dockerfile
end
test "setting dockerfile" do
@deploy_with_builder_option[:builder] = { "dockerfile" => "Dockerfile.dev" }
assert_equal "Dockerfile.dev", @config_with_builder_option.builder.dockerfile
end
test "context" do
assert_equal ".", @config.builder.context
end
test "setting context" do
@deploy_with_builder_option[:builder] = { "context" => ".." }
assert_equal "..", @config_with_builder_option.builder.context
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-web.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-web.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-web.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-web-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\"" ], @config.role(:web).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "traefik.http.services.app-web.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-web.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.middlewares.app-web-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-web-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-web.middlewares=\"app-web-retry@docker\"" ], @config.role(:web).label_args
end end
test "custom labels" do test "custom labels" do
@@ -57,8 +57,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "overwriting default traefik label" do test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" } @deploy[:labels] = { "traefik.http.routers.app-web.rule" => "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"" }
assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", @config.role(:web).labels["traefik.http.routers.app.rule"] assert_equal "\"Host(\\`example.com\\`) || (Host(\\`example.org\\`) && Path(\\`/traefik\\`))\"", @config.role(:web).labels["traefik.http.routers.app-web.rule"]
end end
test "default traefik label on non-web role" do test "default traefik label on non-web role" do
@@ -66,15 +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-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-beta.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-beta.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "traefik.http.services.app-beta.loadbalancer.server.scheme=\"http\"", "--label", "traefik.http.routers.app-beta.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta.middlewares=\"app-beta-retry@docker\"" ], config.role(:beta).label_args
end
test "default traefik label for non-web role with destination" do
config = Mrsk::Configuration.new(@deploy_with_roles.tap { |c|
c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] }
}, destination: "staging")
assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination=\"staging\"", "--label", "traefik.http.routers.app-beta-staging.rule=\"PathPrefix(\\`/\\`)\"", "--label", "traefik.http.services.app-beta-staging.loadbalancer.healthcheck.path=\"/up\"", "--label", "traefik.http.services.app-beta-staging.loadbalancer.healthcheck.interval=\"1s\"", "--label", "traefik.http.middlewares.app-beta-staging-retry.retry.attempts=\"5\"", "--label", "traefik.http.middlewares.app-beta-staging-retry.retry.initialinterval=\"500ms\"", "--label", "traefik.http.routers.app-beta-staging.middlewares=\"app-beta-staging-retry@docker\"" ], config.role(:beta).label_args
end end
test "env overwritten by role" do test "env overwritten by role" do

View File

@@ -48,7 +48,7 @@ class ConfigurationTest < ActiveSupport::TestCase
end end
test "role" do test "role" do
assert_equal "web", @config.role(:web).name assert @config.role(:web).name.web?
assert_equal "workers", @config_with_roles.role(:workers).name assert_equal "workers", @config_with_roles.role(:workers).name
assert_nil @config.role(:missing) assert_nil @config.role(:missing)
end end
@@ -72,19 +72,36 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], 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 no git repo" do
ENV.delete("VERSION") ENV.delete("VERSION")
@config.expects(:system).with("git rev-parse").returns(nil) @config.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @config.version} error = assert_raises(RuntimeError) { @config.version}
assert_match /no git repository found/, error.message assert_match /no git repository found/, error.message
end
@config.expects(:current_commit_hash).returns("git-version") test "version from git committed" do
ENV.delete("VERSION")
@config.expects(:`).with("git rev-parse HEAD").returns("git-version")
@config.expects(:`).with("git status --porcelain").returns("")
assert_equal "git-version", @config.version assert_equal "git-version", @config.version
end
test "version from git uncommitted" do
ENV.delete("VERSION")
@config.expects(:`).with("git rev-parse HEAD").returns("git-version")
@config.expects(:`).with("git status --porcelain").returns("M file\n")
assert_match /^git-version_uncommitted_[0-9a-f]{16}$/, @config.version
end
test "version from env" do
ENV["VERSION"] = "env-version" ENV["VERSION"] = "env-version"
assert_equal "env-version", @config.version assert_equal "env-version", @config.version
end
test "version from arg" do
@config.version = "arg-version" @config.version = "arg-version"
assert_equal "arg-version", @config.version assert_equal "arg-version", @config.version
end end
@@ -149,7 +166,7 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_raises(KeyError) do assert_raises(KeyError) do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({ config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] } env: { "secret" => [ "PASSWORD" ] }
}) }) }) }).ensure_env_available
end end
end end
@@ -194,17 +211,21 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal "root", @config.ssh_options[:user] assert_equal "root", @config.ssh_options[:user]
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) }) config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) })
assert_equal "app", @config.ssh_options[:user] assert_equal "app", config.ssh_options[:user]
assert_equal 4, config.ssh_options[:logger].level
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "log_level" => "debug" }) })
assert_equal 0, config.ssh_options[:logger].level
end end
test "ssh options with proxy host" do test "ssh options with proxy host" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) }) config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) })
assert_equal "root@1.2.3.4", @config.ssh_options[:proxy].jump_proxies assert_equal "root@1.2.3.4", config.ssh_options[:proxy].jump_proxies
end end
test "ssh options with proxy host and user" do test "ssh options with proxy host and user" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) }) config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) })
assert_equal "app@1.2.3.4", @config.ssh_options[:proxy].jump_proxies assert_equal "app@1.2.3.4", config.ssh_options[:proxy].jump_proxies
end end
test "volume_args" do test "volume_args" do
@@ -249,6 +270,37 @@ class ConfigurationTest < ActiveSupport::TestCase
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"], :logging=>["--log-opt", "max-size=\"10m\""], :healthcheck=>{"path"=>"/up", "port"=>3000, "max_attempts" => 7 }}, @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"]},
:ssh_log_level=>4,
:volume_args=>["--volume", "/local/path:/container/path"],
:builder=>{},
:logging=>["--log-opt", "max-size=\"10m\""],
:healthcheck=>{"path"=>"/up", "port"=>3000, "max_attempts" => 7 }
}, @config.to_h)
end
test "min version is lower" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: "0.0.1") })
assert_equal "0.0.1", config.minimum_version
end
test "min version is equal" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: Mrsk::VERSION) })
assert_equal Mrsk::VERSION, config.minimum_version
end
test "min version is higher" do
assert_raises(ArgumentError) do
Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: "10000.0.0") })
end
end end
end end

View File

@@ -0,0 +1,17 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
boot:
limit: 3
wait: 2

View File

@@ -0,0 +1,17 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
boot:
limit: 25%
wait: 2

11
test/fixtures/deploy_with_secrets.yml vendored Normal file
View File

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

View File

@@ -0,0 +1,36 @@
require_relative "integration_test"
class AccessoryTest < IntegrationTest
test "boot, stop, start, restart, logs, remove" do
mrsk :accessory, :boot, :busybox
assert_accessory_running :busybox
mrsk :accessory, :stop, :busybox
assert_accessory_not_running :busybox
mrsk :accessory, :start, :busybox
assert_accessory_running :busybox
mrsk :accessory, :restart, :busybox
assert_accessory_running :busybox
logs = mrsk :accessory, :logs, :busybox, capture: true
assert_match /Starting busybox.../, logs
mrsk :accessory, :remove, :busybox, "-y"
assert_accessory_not_running :busybox
end
private
def assert_accessory_running(name)
assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name)
end
def assert_accessory_not_running(name)
refute_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name)
end
def accessory_details(name)
mrsk :accessory, :details, name, capture: true
end
end

View File

@@ -0,0 +1,55 @@
require_relative "integration_test"
class AppTest < IntegrationTest
test "stop, start, boot, logs, images, containers, exec, remove" do
mrsk :deploy
assert_app_is_up
mrsk :app, :stop
# traefik is up and returns 404s when it can't match a route
assert_app_not_found
mrsk :app, :start
# mrsk app start does not wait
wait_for_app_to_be_up
mrsk :app, :boot
wait_for_app_to_be_up
logs = mrsk :app, :logs, capture: true
assert_match /App Host: vm1/, logs
assert_match /App Host: vm2/, logs
assert_match /GET \/ HTTP\/1.1/, logs
images = mrsk :app, :images, capture: true
assert_match /App Host: vm1/, images
assert_match /App Host: vm2/, images
assert_match /registry:4443\/app\s+#{latest_app_version}/, images
assert_match /registry:4443\/app\s+latest/, images
containers = mrsk :app, :containers, capture: true
assert_match /App Host: vm1/, containers
assert_match /App Host: vm2/, containers
assert_match /registry:4443\/app:#{latest_app_version}/, containers
assert_match /registry:4443\/app:latest/, containers
exec_output = mrsk :app, :exec, :ps, capture: true
assert_match /App Host: vm1/, exec_output
assert_match /App Host: vm2/, exec_output
assert_match /1 root 0:\d\d ps/, exec_output
exec_output = mrsk :app, :exec, "--reuse", :ps, capture: true
assert_match /App Host: vm1/, exec_output
assert_match /App Host: vm2/, exec_output
assert_match /1 root 0:\d\d nginx/, exec_output
mrsk :app, :remove
# traefik is up and returns 404s when it can't match a route
assert_app_not_found
end
end

View File

@@ -0,0 +1,57 @@
version: "3.7"
name: "mrsk-test"
volumes:
shared:
registry:
deployer_bundle:
services:
shared:
build:
context: docker/shared
volumes:
- shared:/shared
deployer:
privileged: true
build:
context: docker/deployer
environment:
- TEST_ID=${TEST_ID}
volumes:
- ../..:/mrsk
- shared:/shared
- registry:/registry
- deployer_bundle:/usr/local/bundle/
registry:
build:
context: docker/registry
environment:
- REGISTRY_HTTP_ADDR=0.0.0.0:4443
- REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt
- REGISTRY_HTTP_TLS_KEY=/certs/domain.key
volumes:
- shared:/shared
- registry:/var/lib/registry/
vm1:
privileged: true
build:
context: docker/vm
volumes:
- shared:/shared
vm2:
privileged: true
build:
context: docker/vm
volumes:
- shared:/shared
load_balancer:
build:
context: docker/load_balancer
ports:
- "12345:80"

View File

@@ -0,0 +1,30 @@
FROM ruby:3.2
WORKDIR /app
RUN apt-get update --fix-missing && apt-get install -y ca-certificates openssh-client curl gnupg docker.io
RUN install -m 0755 -d /etc/apt/keyrings
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
RUN chmod a+r /etc/apt/keyrings/docker.gpg
RUN echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
COPY *.sh .
COPY app/ .
RUN rm -rf /root/.ssh
RUN ln -s /shared/ssh /root/.ssh
RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt
RUN git config --global user.email "deployer@example.com"
RUN git config --global user.name "Deployer"
RUN git init && git add . && git commit -am "Initial version"
HEALTHCHECK --interval=1s CMD pgrep sleep
CMD ["./boot.sh"]

View File

@@ -0,0 +1 @@
SECRET_TOKEN=1234

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Deployed!"
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "About to build and push..."
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build

View File

@@ -0,0 +1,8 @@
#!/bin/sh
echo "About to lock..."
if [ "$MRSK_HOSTS" != "vm1,vm2" ]; then
echo "Expected hosts to be 'vm1,vm2', got $MRSK_HOSTS"
exit 1
fi
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Deployed!"
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy

View File

@@ -0,0 +1,7 @@
FROM registry:4443/nginx:1-alpine-slim
COPY default.conf /etc/nginx/conf.d/default.conf
ARG COMMIT_SHA
RUN echo $COMMIT_SHA > /usr/share/nginx/html/version

View File

@@ -0,0 +1,27 @@
service: app
image: app
servers:
- vm1
- vm2
registry:
server: registry:4443
username: root
password: root
builder:
multiarch: false
args:
COMMIT_SHA: <%= `git rev-parse HEAD` %>
healthcheck:
cmd: wget -qO- http://localhost > /dev/null
traefik:
args:
accesslog: true
accesslog.format: json
image: registry:4443/traefik:v2.9
accessories:
busybox:
image: registry:4443/busybox:1.36.0
cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done'
roles:
- web
stop_wait_time: 1

View File

@@ -0,0 +1,17 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -0,0 +1,5 @@
#!/bin/bash
dockerd --max-concurrent-downloads 1 &
exec sleep infinity

View File

@@ -0,0 +1,23 @@
#!/bin/bash
install_mrsk() {
cd /mrsk && gem build mrsk.gemspec -o /tmp/mrsk.gem && gem install /tmp/mrsk.gem
}
# Push the images to a persistent volume on the registry container
# This is to work around docker hub rate limits
push_image_to_registry_4443() {
# Check if the image is in the registry without having to pull it
if ! stat /registry/docker/registry/v2/repositories/$1/_manifests/tags/$2/current/link > /dev/null; then
hub_tag=$1:$2
registry_4443_tag=registry:4443/$1:$2
docker pull $hub_tag
docker tag $hub_tag $registry_4443_tag
docker push $registry_4443_tag
fi
}
install_mrsk
push_image_to_registry_4443 nginx 1-alpine-slim
push_image_to_registry_4443 traefik v2.9
push_image_to_registry_4443 busybox 1.36.0

View File

@@ -0,0 +1,3 @@
#!/bin/bash
git commit -am 'Update rev' --amend

View File

@@ -0,0 +1,5 @@
FROM nginx:1-alpine-slim
COPY default.conf /etc/nginx/conf.d/default.conf
HEALTHCHECK --interval=1s CMD pgrep nginx

View File

@@ -0,0 +1,16 @@
upstream loadbalancer {
server vm1:80;
server vm2:80;
}
server {
listen 80;
location / {
proxy_pass http://loadbalancer;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
send_timeout 10;
}
}

View File

@@ -0,0 +1,9 @@
FROM registry
COPY boot.sh .
RUN ln -s /shared/certs /certs
HEALTHCHECK --interval=1s CMD pgrep registry
ENTRYPOINT ["./boot.sh"]

View File

@@ -0,0 +1,5 @@
#!/bin/sh
while [ ! -f /certs/domain.crt ]; do sleep 1; done
exec /entrypoint.sh /etc/docker/registry/config.yml

View File

@@ -0,0 +1,17 @@
FROM ubuntu:22.10
WORKDIR /work
RUN apt-get update --fix-missing && apt-get -y install openssh-client openssl
RUN mkdir ssh && \
ssh-keygen -t rsa -f ssh/id_rsa -N ""
COPY registry-dns.conf .
COPY boot.sh .
RUN mkdir certs && openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt -subj '/CN=registry' -extensions EXT -config registry-dns.conf
HEALTHCHECK --interval=1s CMD pgrep sleep
CMD ["./boot.sh"]

View File

@@ -0,0 +1,5 @@
#!/bin/bash
cp -r * /shared
exec sleep infinity

View File

@@ -0,0 +1,7 @@
[dn]
CN=registry
[req]
distinguished_name = dn
[EXT]
subjectAltName=DNS:registry
keyUsage=digitalSignature

View File

@@ -0,0 +1,14 @@
FROM ubuntu:22.10
WORKDIR /work
RUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io
RUN mkdir /root/.ssh && ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys
RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt
COPY boot.sh .
HEALTHCHECK --interval=1s CMD pgrep dockerd
CMD ["./boot.sh"]

View File

@@ -0,0 +1,9 @@
#!/bin/bash
while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep 1; done
service ssh restart
dockerd --max-concurrent-downloads 1 &
exec sleep infinity

View File

@@ -0,0 +1,138 @@
require "net/http"
require "test_helper"
class IntegrationTest < ActiveSupport::TestCase
setup do
ENV["TEST_ID"] = SecureRandom.hex
docker_compose "up --build -d"
wait_for_healthy
setup_deployer
end
teardown do
unless passed?
[:deployer, :vm1, :vm2, :shared, :load_balancer, :registry].each do |container|
puts
puts "Logs for #{container}:"
docker_compose :logs, container
end
end
docker_compose "down -t 1"
end
private
def docker_compose(*commands, capture: false, raise_on_error: true)
command = "TEST_ID=#{ENV["TEST_ID"]} docker compose #{commands.join(" ")}"
succeeded = false
if capture
result = stdouted { succeeded = system("cd test/integration && #{command}") }
else
succeeded = system("cd test/integration && #{command}")
end
raise "Command `#{command}` failed with error code `#{$?}`" if !succeeded && raise_on_error
result
end
def deployer_exec(*commands, **options)
docker_compose("exec deployer #{commands.join(" ")}", **options)
end
def mrsk(*commands, **options)
deployer_exec(:mrsk, *commands, **options)
end
def assert_app_is_down
response = app_response
debug_response_code(response, "502")
assert_equal "502", response.code
end
def assert_app_is_up(version: nil)
response = app_response
debug_response_code(response, "200")
assert_equal "200", response.code
assert_app_version(version, response) if version
end
def assert_app_not_found
response = app_response
debug_response_code(response, "404")
assert_equal "404", response.code
end
def wait_for_app_to_be_up(timeout: 20, up_count: 3)
timeout_at = Time.now + timeout
up_times = 0
response = app_response
while up_times < up_count && timeout_at > Time.now
sleep 0.1
up_times += 1 if response.code == "200"
response = app_response
end
assert_equal up_times, up_count
end
def app_response
Net::HTTP.get_response(URI.parse("http://localhost:12345/version"))
end
def update_app_rev
deployer_exec "./update_app_rev.sh"
latest_app_version
end
def latest_app_version
deployer_exec("git rev-parse HEAD", capture: true)
end
def assert_app_version(version, response)
assert_equal version, response.body.strip
end
def assert_hooks_ran(*hooks)
hooks.each do |hook|
file = "/tmp/#{ENV["TEST_ID"]}/#{hook}"
assert_equal "removed '#{file}'", deployer_exec("rm -v #{file}", capture: true).strip
end
end
def assert_200(response)
code = response.code
if code != "200"
puts "Got response code #{code}, here are the traefik logs:"
mrsk :traefik, :logs
puts "And here are the load balancer logs"
docker_compose :logs, :load_balancer
puts "Tried to get the response code again and got #{app_response.code}"
end
assert_equal "200", code
end
def wait_for_healthy(timeout: 20)
timeout_at = Time.now + timeout
while docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true) != "0"
if timeout_at < Time.now
docker_compose("ps -a | tail -n +2 | grep -v '(healthy)'")
raise "Container not healthy after #{timeout} seconds" if timeout_at < Time.now
end
sleep 0.1
end
end
def setup_deployer
deployer_exec("./setup.sh") unless $DEPLOYER_SETUP
$DEPLOYER_SETUP = true
end
def debug_response_code(app_response, expected_code)
code = app_response.code
if code != expected_code
puts "Got response code #{code}, here are the traefik logs:"
mrsk :traefik, :logs
puts "And here are the load balancer logs"
docker_compose :logs, :load_balancer
puts "Tried to get the response code again and got #{app_response.code}"
end
end
end

View File

@@ -0,0 +1,18 @@
require_relative "integration_test"
class LockTest < IntegrationTest
test "acquire, release, status" do
mrsk :lock, :acquire, "-m 'Integration Tests'"
status = mrsk :lock, :status, capture: true
assert_match /Locked by: Deployer at .*\nVersion: #{latest_app_version}\nMessage: Integration Tests/m, status
error = mrsk :deploy, capture: true, raise_on_error: false
assert_match /Deploy lock found/m, error
mrsk :lock, :release
status = mrsk :lock, :status, capture: true
assert_match /There is no deploy lock/m, status
end
end

View File

@@ -0,0 +1,59 @@
require_relative "integration_test"
class MainTest < IntegrationTest
test "deploy, redeploy, rollback, details and audit" do
first_version = latest_app_version
assert_app_is_down
mrsk :deploy
assert_app_is_up version: first_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
second_version = update_app_rev
mrsk :redeploy
assert_app_is_up version: second_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy"
mrsk :rollback, first_version
assert_hooks_ran "pre-connect", "pre-deploy", "post-deploy"
assert_app_is_up version: first_version
details = mrsk :details, capture: true
assert_match /Traefik Host: vm1/, details
assert_match /Traefik Host: vm2/, details
assert_match /App Host: vm1/, details
assert_match /App Host: vm2/, details
assert_match /traefik:v2.9/, details
assert_match /registry:4443\/app:#{first_version}/, details
audit = mrsk :audit, capture: true
assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit
end
test "envify" do
mrsk :envify
assert_equal "SECRET_TOKEN=1234", deployer_exec("cat .env", capture: true)
end
test "config" do
config = YAML.load(mrsk(:config, capture: true))
version = latest_app_version
assert_equal [ "web" ], config[:roles]
assert_equal [ "vm1", "vm2" ], config[:hosts]
assert_equal "vm1", config[:primary_host]
assert_equal version, config[:version]
assert_equal "registry:4443/app", config[:repository]
assert_equal "registry:4443/app:#{version}", config[:absolute_image]
assert_equal "app-#{version}", config[:service_with_version]
assert_equal [], config[:env_args]
assert_equal [], config[:volume_args]
assert_equal({ user: "root", auth_methods: [ "publickey" ] }, config[:ssh_options])
assert_equal({ "multiarch" => false, "args" => { "COMMIT_SHA" => version } }, config[:builder])
assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging]
assert_equal({ "path" => "/up", "port" => 3000, "max_attempts" => 7, "cmd" => "wget -qO- http://localhost > /dev/null" }, config[:healthcheck])
end
end

View File

@@ -0,0 +1,36 @@
require_relative "integration_test"
class TraefikTest < IntegrationTest
test "boot, stop, start, restart, logs, remove" do
mrsk :traefik, :boot
assert_traefik_running
mrsk :traefik, :stop
assert_traefik_not_running
mrsk :traefik, :start
assert_traefik_running
mrsk :traefik, :restart
assert_traefik_running
logs = mrsk :traefik, :logs, capture: true
assert_match /Traefik version [\d.]+ built on/, logs
mrsk :traefik, :remove
assert_traefik_not_running
end
private
def assert_traefik_running
assert_match /traefik:v2.9 "\/entrypoint.sh/, traefik_details
end
def assert_traefik_not_running
refute_match /traefik:v2.9 "\/entrypoint.sh/, traefik_details
end
def traefik_details
mrsk :traefik, :details, capture: true
end
end

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