Compare commits

..

61 Commits

Author SHA1 Message Date
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
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
19527b4f65 Merge branch 'main' into customizable-audit-broadcast 2023-05-02 10:25:25 +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
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
64 changed files with 1166 additions and 416 deletions

View File

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

100
README.md
View File

@@ -44,24 +44,24 @@ Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSW
Now you're ready to deploy to the servers:
```
mrsk deploy
mrsk setup
```
This will:
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
4. Build the image using the standard Dockerfile in the root of the application.
5. Push the image to the registry.
6. Pull the image from the registry onto the servers.
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.
10. Stop the old container running the previous version of the app.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. For subsequent deploys, or if your servers already have Docker and curl installed, you can just run `mrsk deploy`.
## Vision
@@ -184,6 +184,19 @@ registry:
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
The default SSH user is root, but you can change it using `ssh/user`:
@@ -308,7 +321,7 @@ You can specialize the default Traefik rules by setting labels on the containers
labels:
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!
@@ -522,7 +535,7 @@ traefik:
options:
publish:
- 8080:8080
volumes:
volume:
- /tmp/example.json:/tmp/example.json
memory: 512m
```
@@ -655,28 +668,6 @@ servers:
This assumes the Cron settings are stored in `config/crontab`.
### Using audit broadcasts
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument:
```yaml
audit_broadcast_cmd:
bin/audit_broadcast
```
The broadcast command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like follows to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
### Healthcheck
MRSK uses Docker healtchecks 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.
@@ -688,6 +679,7 @@ healthcheck:
path: /healthz
port: 4000
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.
@@ -714,7 +706,7 @@ servers:
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 that the HTTP health checks assume that the `curl` command is avilable inside the container. If that's not the case, use the healthcheck's `cmd` option to specify an alternative check that the container supports.
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
@@ -873,6 +865,56 @@ When `limit` is specified, containers will be booted on, at most, `limit` hosts
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` - an full version being deployed
- `MRSK_DESTINATION` - optional: destination, e.g. "staging"
- `MRSK_HOSTS` - a comma separated list of the hosts targeted by the command
- `MRSK_ROLE` - optional: role targeted, e.g. "web"
There are three 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. post-deploy - run after a deploy, redeploy or rollback
This hook is also passed a `MRSK_RUNTIME` env variable.
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 follows 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
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
Mrsk::Cli::Main.start(ARGV)
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"]
exit 1
rescue => e

View File

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

View File

@@ -14,8 +14,6 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}" unless options[:skip_broadcast]
end
end
end

View File

@@ -2,37 +2,39 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)"
def boot
with_lock do
say "Get most recent version available as an image...", :magenta unless options[:version]
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
hold_lock_on_error do
say "Get most recent version available as an image...", :magenta unless options[:version]
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
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
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, **MRSK.boot_strategy) do |host|
roles = MRSK.roles_on(host)
on(MRSK.hosts, **MRSK.boot_strategy) do |host|
roles = MRSK.roles_on(host)
roles.each do |role|
app = MRSK.app(role: role)
auditor = MRSK.auditor(role: role)
roles.each do |role|
app = MRSK.app(role: role)
auditor = MRSK.auditor(role: role)
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
if capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).present?
tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
execute *app.rename_container(version: version, new_version: tmp_version)
end
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
tmp_version = "#{version}_#{SecureRandom.hex(8)}"
info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
execute *app.rename_container(version: version, new_version: tmp_version)
execute *auditor.record("Booted app version #{version}"), verbosity: :debug
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *app.start_or_run
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
old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
execute *app.run
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
@@ -60,7 +62,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
roles = MRSK.roles_on(host)
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
end
end
@@ -107,7 +109,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
roles = MRSK.roles_on(host)
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))
end
end
@@ -214,7 +216,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
roles = MRSK.roles_on(host)
roles.each do |role|
execute *MRSK.auditor(role: role).record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
execute *MRSK.app(role: role).remove_container(version: version)
end
end
@@ -228,7 +230,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
roles = MRSK.roles_on(host)
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
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 :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(*)
super
@@ -72,14 +72,12 @@ module Mrsk::Cli
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
def with_lock
if MRSK.holding_lock?
yield
else
run_hook "pre-connect"
acquire_lock
begin
@@ -99,26 +97,32 @@ module Mrsk::Cli
end
def acquire_lock
say "Acquiring the deploy lock"
on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version) }
raise_if_locked do
say "Acquiring the deploy lock...", :magenta
on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version), verbosity: :debug }
end
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
if e.message =~ /cannot create directory/
on(MRSK.primary_host) { execute *MRSK.lock.status }
on(MRSK.primary_host) { puts capture_with_debug(*MRSK.lock.status) }
raise LockError, "Deploy lock found"
else
raise e
end
end
def release_lock
say "Releasing the deploy lock"
on(MRSK.primary_host) { execute *MRSK.lock.release }
MRSK.holding_lock = false
end
def hold_lock_on_error
if MRSK.hold_lock_on_error?
yield
@@ -128,5 +132,16 @@ module Mrsk::Cli
MRSK.hold_lock_on_error = false
end
end
def run_hook(hook, **details)
if !options[:skip_hooks] && MRSK.hook.hook_exists?(hook)
say "Running the #{hook} hook...", :magenta
run_locally do
MRSK.with_verbosity(:debug) { execute *MRSK.hook.run(hook, **details, hosts: MRSK.hosts.join(",")) }
rescue SSHKit::Command::Failed
raise HookError.new("Hook `#{hook}` failed")
end
end
end
end
end

View File

@@ -14,11 +14,12 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
with_lock do
cli = self
verify_local_dependencies
run_hook "pre-build"
run_locally do
begin
if cli.verify_local_dependencies
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
end
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/
error "Missing compatible builder, so creating a new one first"
@@ -82,21 +83,18 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
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"
desc "", "" # Really a private method, but needed to be invoked from #push
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
raise BuildError, build_error
end
end
end
true
end
end

View File

@@ -9,6 +9,7 @@ class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base
Mrsk::Utils::HealthcheckPoller.wait_for_healthy { capture_with_info(*MRSK.healthcheck.status) }
rescue Mrsk::Utils::HealthcheckPoller::HealthcheckError => e
error capture_with_info(*MRSK.healthcheck.logs)
error capture_with_pretty_json(*MRSK.healthcheck.container_health_log)
raise
ensure
execute *MRSK.healthcheck.stop, raise_on_non_zero_exit: false

View File

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

View File

@@ -1,8 +1,8 @@
class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy app to servers"
def setup
with_lock do
print_runtime do
print_runtime do
with_lock do
invoke "mrsk:cli:server:bootstrap"
invoke "mrsk:cli:accessory:boot", [ "all" ]
deploy
@@ -13,10 +13,10 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "deploy", "Deploy app to servers"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy
with_lock do
invoke_options = deploy_options
runtime = print_runtime do
with_lock do
invoke_options = deploy_options
runtime = print_runtime do
say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login", [], invoke_options
@@ -37,25 +37,23 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
say "Detect stale containers...", :magenta
invoke "mrsk:cli:app:stale_containers", [], invoke_options
hold_lock_on_error do
invoke "mrsk:cli:app:boot", [], invoke_options
end
invoke "mrsk:cli:app:boot", [], invoke_options
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all", [], invoke_options
end
audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end
run_hook "post-deploy", runtime: runtime.round
end
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def redeploy
with_lock do
invoke_options = deploy_options
runtime = print_runtime do
with_lock do
invoke_options = deploy_options
runtime = print_runtime do
if options[:skip_push]
say "Pull app image...", :magenta
invoke "mrsk:cli:build:pull", [], invoke_options
@@ -70,55 +68,33 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
say "Detect stale containers...", :magenta
invoke "mrsk:cli:app:stale_containers", [], invoke_options
hold_lock_on_error do
invoke "mrsk:cli:app:boot", [], invoke_options
end
invoke "mrsk:cli:app:boot", [], invoke_options
end
audit_broadcast "Redeployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
end
run_hook "post-deploy", runtime: runtime.round
end
desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version)
with_lock do
invoke_options = deploy_options
rolled_back = false
runtime = print_runtime do
with_lock do
invoke_options = deploy_options
hold_lock_on_error do
MRSK.config.version = version
old_version = nil
if container_available?(version)
say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
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|
roles = MRSK.roles_on(host)
roles.each do |role|
app = MRSK.app(role: role)
old_version = capture_with_info(*app.current_running_version).strip.presence
execute *app.start
if old_version
sleep MRSK.config.readiness_delay
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
end
end
end
audit_broadcast "Rolled back #{service_version(Mrsk::Utils.abbreviate_version(old_version))} to #{service_version}" unless options[:skip_broadcast]
invoke "mrsk:cli:app:boot", [], invoke_options.merge(version: version)
rolled_back = true
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end
end
end
run_hook "post-deploy", runtime: runtime.round if rolled_back
end
desc "details", "Show details about all containers"
@@ -160,6 +136,14 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
puts "Created .env file"
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 (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
@@ -217,6 +201,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "lock", "Manage the deploy lock"
subcommand "lock", Mrsk::Cli::Lock
desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune
@@ -229,9 +216,6 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "traefik", "Manage Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik
desc "lock", "Manage the deploy lock"
subcommand "lock", Mrsk::Cli::Lock
private
def container_available?(version)
begin
@@ -256,8 +240,4 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
def deploy_options
{ "version" => MRSK.config.version }.merge(options.without("skip_push"))
end
def service_version(version = MRSK.config.abbreviated_version)
[ MRSK.config.service, version ].compact.join("@")
end
end

View File

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

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

@@ -84,8 +84,8 @@ class Mrsk::Commander
Mrsk::Commands::Accessory.new(config, name: name)
end
def auditor(role: nil)
Mrsk::Commands::Auditor.new(config, role: role)
def auditor(**details)
Mrsk::Commands::Auditor.new(config, **details)
end
def builder
@@ -100,6 +100,14 @@ class Mrsk::Commander
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def hook
@hook ||= Mrsk::Commands::Hook.new(config)
end
def lock
@lock ||= Mrsk::Commands::Lock.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
@@ -112,10 +120,6 @@ class Mrsk::Commander
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def lock
@lock ||= Mrsk::Commands::Lock.new(config)
end
def with_verbosity(level)
old_level = self.verbosity

View File

@@ -6,6 +6,10 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
@role = role
end
def start_or_run
combine start, run, by: "||"
end
def run
role = config.role(self.role)
@@ -91,8 +95,8 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :ps, "--quiet", *filter_args(status: :running), "--latest"
end
def container_id_for_version(version)
container_id_for(container_name: container_name(version))
def container_id_for_version(version, only_running: false)
container_id_for(container_name: container_name(version), only_running: only_running)
end
def current_running_version

View File

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

View File

@@ -3,6 +3,7 @@ module Mrsk::Commands
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
@@ -17,8 +18,8 @@ module Mrsk::Commands
end
end
def container_id_for(container_name:)
docker :container, :ls, "--all", "--filter", "name=^#{container_name}$", "--quiet"
def container_id_for(container_name:, only_running: false)
docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
end
private
@@ -52,5 +53,9 @@ module Mrsk::Commands
def docker(*args)
args.compact.unshift :docker
end
def tags(**details)
Mrsk::Tags.from_config(config, **details)
end
end
end

View File

@@ -22,6 +22,10 @@ class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
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
def logs
pipe container_id, xargs(docker(:logs, "--tail", 50, "2>&1"))
end

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/core_ext/numeric/time"
require "time"
class Mrsk::Commands::Lock < Mrsk::Commands::Base
def acquire(message, version)
@@ -49,7 +49,7 @@ class Mrsk::Commands::Lock < Mrsk::Commands::Base
def lock_details(message, version)
<<~DETAILS.strip
Locked by: #{locked_by} at #{Time.now.gmtime}
Locked by: #{locked_by} at #{Time.now.utc.iso8601}
Version: #{version}
Message: #{message}
DETAILS

View File

@@ -3,7 +3,7 @@ require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Prune < Mrsk::Commands::Base
def images
docker :image, :prune, "--all", "--force", "--filter", "label=service=#{config.service}", "--filter", "dangling=true"
docker :image, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "dangling=true"
end
def containers(keep_last: 5)

View File

@@ -1,5 +1,5 @@
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"
CONTAINER_PORT = 80
@@ -10,6 +10,7 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
"--restart", "unless-stopped",
"--publish", port,
"--volume", "/var/run/docker.sock:/var/run/docker.sock",
*env_args,
*config.logging_args,
*label_args,
*docker_options_args,
@@ -61,6 +62,16 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
argumentize "--label", labels
end
def env_args
env_config = config.traefik["env"] || {}
if env_config.present?
argumentize_env_with_secrets(env_config)
else
[]
end
end
def labels
config.traefik["labels"] || []
end

View File

@@ -6,7 +6,7 @@ require "erb"
require "net/ssh/proxy/jump"
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, :builder, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
attr_accessor :destination
@@ -50,7 +50,7 @@ class Mrsk::Configuration
end
def version
@declared_version.presence || ENV["VERSION"] || current_commit_hash
@declared_version.presence || ENV["VERSION"] || git_version
end
def abbreviated_version
@@ -157,10 +157,6 @@ class Mrsk::Configuration
end
def audit_broadcast_cmd
raw_config.audit_broadcast_cmd
end
def healthcheck
{ "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
end
@@ -197,6 +193,10 @@ class Mrsk::Configuration
raw_config.traefik || {}
end
def hooks_path
raw_config.hooks_path || ".mrsk/hooks"
end
private
# Will raise ArgumentError if any required config keys are missing
def ensure_required_keys_present
@@ -233,10 +233,12 @@ class Mrsk::Configuration
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end
def current_commit_hash
@current_commit_hash ||=
def git_version
@git_version ||=
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
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end

View File

@@ -37,7 +37,7 @@ class Mrsk::Configuration::Role
def health_check_args
if health_check_cmd.present?
optionize({ "health-cmd" => health_check_cmd, "health-interval" => "1s" })
optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
else
[]
end
@@ -50,6 +50,13 @@ class Mrsk::Configuration::Role
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
specializations["cmd"]
end

View File

@@ -1,12 +1,56 @@
require "sshkit"
require "sshkit/dsl"
require "active_support/core_ext/hash/deep_merge"
require "json"
class SSHKit::Backend::Abstract
def capture_with_info(*args, **kwargs)
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
def puts_by_host(host, output, type: "App")
puts "#{type} Host: #{host}\n#{output}\n\n"
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

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

@@ -84,6 +84,13 @@ module Mrsk::Utils
# Abbreviate a git revhash for concise display
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

View File

@@ -1,3 +1,3 @@
module Mrsk
VERSION = "0.12.0"
VERSION = "0.13.0"
end

View File

@@ -2,12 +2,7 @@ require_relative "cli_test_case"
class CliAppTest < CliTestCase
test "boot" do
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
stub_running
run_command("boot").tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match "docker run --detach --restart unless-stopped", output
@@ -19,7 +14,7 @@ class CliAppTest < CliTestCase
run_command("details") # Preheat MRSK const
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.with(:docker, :container, :ls, "--filter", "name=^app-web-latest$", "--quiet", raise_on_non_zero_exit: false)
.returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -32,7 +27,7 @@ class CliAppTest < CliTestCase
run_command("boot").tap do |output|
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
assert_match /docker rename .* .*/, output
assert_match /docker 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 container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
end
@@ -50,6 +45,18 @@ class CliAppTest < CliTestCase
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
run_command("start").tap do |output|
assert_match "docker start app-web-999", output
@@ -171,4 +178,12 @@ class CliAppTest < CliTestCase
def run_command(*command, config: :with_accessories)
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

View File

@@ -9,17 +9,19 @@ class CliBuildTest < CliTestCase
end
test "push" do
Mrsk::Cli::Build.any_instance.stubs(:verify_local_dependencies).returns(true)
run_command("push").tap do |output|
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
end
end
test "push without builder" do
stub_locking
Mrsk::Cli::Build.any_instance.stubs(:verify_local_dependencies).returns(true)
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"))
.then
.returns(true)
@@ -29,6 +31,24 @@ class CliBuildTest < CliTestCase
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
run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output
@@ -70,32 +90,15 @@ class CliBuildTest < CliTestCase
end
end
test "verify local dependencies" do
Mrsk::Commands::Builder.any_instance.stubs(:name).returns("remote".inquiry)
run_command("verify_local_dependencies").tap do |output|
assert_match /docker --version && docker buildx version/, output
end
end
test "verify local dependencies with no buildx plugin" do
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("verify_local_dependencies") }
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
def stub_locking
def stub_dependency_checks
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)
.with { |arg1, arg2| arg1 == :rm && arg2 == "mrsk_lock/details" }
.with { |*args| args[0..1] == [:docker, :buildx] }
end
end

View File

@@ -1,8 +1,6 @@
require "test_helper"
class CliTestCase < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do
ENV["VERSION"] = "999"
ENV["RAILS_MASTER_KEY"] = "123"
@@ -16,4 +14,32 @@ class CliTestCase < ActiveSupport::TestCase
ENV.delete("MYSQL_ROOT_PASSWORD")
ENV.delete("VERSION")
end
private
def fail_hook(hook)
@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
def ensure_hook_runs(hook)
Mrsk::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args != [".mrsk/hooks/#{hook}"] }
SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with { |*args| args.first == ".mrsk/hooks/#{hook}" }
.once
end
def stub_locking
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :mkdir && arg2 == :mrsk_lock }
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |arg1, arg2| arg1 == :rm && arg2 == "mrsk_lock/details" }
end
end

View File

@@ -53,6 +53,11 @@ class CliHealthcheckTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :logs, "--tail", 50, "2>&1")
.returns("some log output")
# Capture container health log when failing
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_pretty_json)
.with(:docker, :container, :ls, "--all", "--filter", "name=^healthcheck-app-999$", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{json .State.Health}}'")
.returns('{"Status":"unhealthy","Log":[{"ExitCode": 1,"Output": "/bin/sh: 1: curl: not found\n"}]}"')
exception = assert_raises do
run_command("perform")
end

View File

@@ -10,7 +10,7 @@ class CliMainTest < CliTestCase
end
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:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:deliver", [], invoke_options)
@@ -20,18 +20,22 @@ class CliMainTest < CliTestCase
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::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("deploy").tap do |output|
assert_match /Running the pre-connect hook.../, output
assert_match /Log into image registry/, output
assert_match /Build and push app image/, output
assert_match /Ensure Traefik is running/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output
assert_match /Running the post-deploy hook.../, output
end
end
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:registry:login", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:build:pull", [], invoke_options)
@@ -60,7 +64,7 @@ class CliMainTest < CliTestCase
.with { |*arg| arg[0..1] == [:mkdir, :mrsk_lock] }
.raises(RuntimeError, "mkdir: cannot create directory mrsk_lock: File exists")
SSHKit::Backend::Abstract.any_instance.expects(:execute)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug)
.with(:stat, :mrsk_lock, ">", "/dev/null", "&&", :cat, "mrsk_lock/details", "|", :base64, "-d")
assert_raises(Mrsk::Cli::LockError) do
@@ -80,25 +84,8 @@ class CliMainTest < CliTestCase
end
end
test "deploy errors during critical section leave lock in place" do
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_broadcast" => false, "version" => "999" }
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli: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:app:stale_containers", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:traefik:boot", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:healthcheck:perform", [], invoke_options)
Mrsk::Cli::Main.any_instance.expects(:invoke).with("mrsk:cli:app:boot", [], invoke_options).raises(RuntimeError)
assert !MRSK.holding_lock?
assert_raises(RuntimeError) do
stderred { run_command("deploy") }
end
assert MRSK.holding_lock?
end
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)
.with("mrsk:cli:registry:login", [], invoke_options)
@@ -111,22 +98,41 @@ class CliMainTest < CliTestCase
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 "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: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::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("redeploy").tap do |output|
assert_match /Build and push app image/, output
assert_match /Ensure app can pass healthcheck/, output
assert_match /Running the post-deploy hook.../, output
end
end
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:healthcheck:perform", [], invoke_options)
@@ -151,35 +157,50 @@ class CliMainTest < CliTestCase
end
test "rollback good version" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet")
.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-workers-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=web", "--filter", "status=running", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-")
.returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=workers", "--filter", "status=running", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-")
.returns("version-to-rollback\n").at_least_once
[ "web", "workers" ].each do |role|
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.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", "--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)
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_match "docker tag dhh/app:123 dhh/app:latest", 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 "Running the post-deploy hook...", output
end
end
test "rollback without old version" do
Mrsk::Cli::Main.any_instance.stubs(:container_available?).returns(true)
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--filter", "status=running", "--latest", "--format", "\"{{.Names}}\"", "|", "grep -oE \"\\-[^-]+$\"", "|", "cut -c 2-").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", "--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|
assert_match "Start version 123", output
assert_match "docker start app-web-123", output
assert_match "Start container with version 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
end
end
@@ -239,9 +260,11 @@ class CliMainTest < CliTestCase
end
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(:cp_r)
FileUtils.stubs(:cp)
run_command("init").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output
@@ -250,7 +273,7 @@ class CliMainTest < CliTestCase
end
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|
assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output
@@ -258,9 +281,11 @@ class CliMainTest < CliTestCase
end
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(:cp_r)
FileUtils.stubs(:cp)
run_command("init", "--bundle").tap do |output|
assert_match /Created configuration file in config\/deploy.yml/, output
@@ -273,9 +298,11 @@ class CliMainTest < CliTestCase
end
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(:cp_r)
FileUtils.stubs(:cp)
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

View File

@@ -10,7 +10,7 @@ class CliPruneTest < CliTestCase
test "images" do
run_command("images").tap do |output|
assert_match /docker image prune --all --force --filter label=service=app --filter dangling=true on 1.1.1.\d/, output
assert_match /docker image prune --force --filter label=service=app --filter dangling=true on 1.1.1.\d/, output
end
end

View File

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

View File

@@ -51,6 +51,12 @@ class CommandsHealthcheckTest < ActiveSupport::TestCase
new_command.status.join(" ")
end
test "container_health_log" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker inspect --format '{{json .State.Health}}'",
new_command.container_health_log.join(" ")
end
test "stop" do
assert_equal \
"docker container ls --all --filter name=^healthcheck-app-123$ --quiet | xargs docker stop",

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,7 +10,7 @@ class CommandsPruneTest < ActiveSupport::TestCase
test "images" do
assert_equal \
"docker image prune --all --force --filter label=service=app --filter dangling=true",
"docker image prune --force --filter label=service=app --filter dangling=true",
new_command.images.join(" ")
end

View File

@@ -8,6 +8,12 @@ class CommandsTraefikTest < ActiveSupport::TestCase
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" } }
}
ENV["EXAMPLE_API_KEY"] = "456"
end
teardown do
ENV.delete("EXAMPLE_API_KEY")
end
test "run" do
@@ -65,6 +71,17 @@ class CommandsTraefikTest < ActiveSupport::TestCase
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(" ")
end
test "run without configuration" do
@config.delete(:traefik)

View File

@@ -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
end
test "version" do
test "version no git repo" do
ENV.delete("VERSION")
@config.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @config.version}
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
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"
assert_equal "env-version", @config.version
end
test "version from arg" do
@config.version = "arg-version"
assert_equal "arg-version", @config.version
end

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

@@ -1,79 +0,0 @@
require "net/http"
require "test_helper"
class DeployTest < ActiveSupport::TestCase
setup do
docker_compose "up --build --force-recreate -d"
wait_for_healthy
end
teardown do
docker_compose "down -v"
end
test "deploy" do
assert_app_is_down
mrsk :deploy
assert_app_is_up
end
private
def docker_compose(*commands, capture: false)
command = "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 `#{$?}`" unless succeeded
result
end
def deployer_exec(*commands, capture: false)
if capture
stdouted { docker_compose("exec deployer #{commands.join(" ")}") }
else
docker_compose("exec deployer #{commands.join(" ")}", capture: capture)
end
end
def mrsk(*commands, capture: false)
deployer_exec(:mrsk, *commands, capture: capture)
end
def assert_app_is_down
assert_equal "502", app_response.code
end
def assert_app_is_up
code = app_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 app_response
Net::HTTP.get_response(URI.parse("http://localhost:12345"))
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
end

View File

@@ -3,6 +3,8 @@ name: "mrsk-test"
volumes:
shared:
registry:
deployer_bundle:
services:
shared:
@@ -15,9 +17,13 @@ services:
privileged: true
build:
context: docker/deployer
environment:
- TEST_ID=${TEST_ID}
volumes:
- ../..:/mrsk
- shared:/shared
- registry:/registry
- deployer_bundle:/usr/local/bundle/
registry:
build:
@@ -28,6 +34,7 @@ services:
- REGISTRY_HTTP_TLS_KEY=/certs/domain.key
volumes:
- shared:/shared
- registry:/var/lib/registry/
vm1:
privileged: true

View File

@@ -14,7 +14,7 @@ RUN echo \
RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
COPY boot.sh .
COPY *.sh .
COPY app/ .
RUN ln -s /shared/ssh /root/.ssh

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

@@ -1,3 +1,7 @@
FROM nginx:1-alpine-slim
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

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

@@ -1,9 +1,5 @@
#!/bin/bash
cd /mrsk && gem build mrsk.gemspec -o /tmp/mrsk.gem && gem install /tmp/mrsk.gem
dockerd --max-concurrent-downloads 1 &
dockerd &
trap "pkill -f sleep" term
sleep infinity & wait
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

@@ -8,5 +8,9 @@ server {
location / {
proxy_pass http://loadbalancer;
proxy_connect_timeout 5;
proxy_send_timeout 5;
proxy_read_timeout 5;
send_timeout 5;
}
}

View File

@@ -2,6 +2,4 @@
while [ ! -f /certs/domain.crt ]; do sleep 1; done
trap "pkill -f registry" term
/entrypoint.sh /etc/docker/registry/config.yml & wait
exec /entrypoint.sh /etc/docker/registry/config.yml

View File

@@ -2,6 +2,4 @@
cp -r * /shared
trap "pkill -f sleep" term
sleep infinity & wait
exec sleep infinity

View File

@@ -4,8 +4,6 @@ while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep
service ssh restart
dockerd &
dockerd --max-concurrent-downloads 1 &
trap "pkill -f sleep" term
sleep infinity & wait
exec sleep infinity

View File

@@ -0,0 +1,131 @@
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
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: 10, 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", "post-deploy"
second_version = update_app_rev
mrsk :redeploy
assert_app_is_up version: second_version
assert_hooks_ran "pre-connect", "pre-build", "post-deploy"
mrsk :rollback, first_version
assert_hooks_ran "pre-connect", "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