Compare commits

...

79 Commits

Author SHA1 Message Date
David Heinemeier Hansson
fb86123db4 Bump version for 0.8.2 2023-02-23 12:28:29 +01:00
David Heinemeier Hansson
bc6963e6bf Note that rebooting may cause air gap 2023-02-23 12:16:58 +01:00
David Heinemeier Hansson
f4f2b5cb17 Communicate the readiness delay 2023-02-23 12:04:57 +01:00
David Heinemeier Hansson
817336df49 No readiness delay in testing 2023-02-23 12:03:03 +01:00
David Heinemeier Hansson
4c399a74bb Update to match latest 2023-02-23 12:02:56 +01:00
David Heinemeier Hansson
e12436a1db Extract readiness_delay to config 2023-02-23 12:02:49 +01:00
David Heinemeier Hansson
b244e919bf Merge branch 'main' into more-resilient-zero-downtime-deploy
* main:
  Add option to skip audit broadcasts (useful when testing)
2023-02-23 11:52:45 +01:00
David Heinemeier Hansson
7ad416f029 Add option to skip audit broadcasts (useful when testing) 2023-02-23 10:04:35 +01:00
David Heinemeier Hansson
371f98d67f Start before stopping and longer timeouts 2023-02-22 19:04:23 +01:00
David Heinemeier Hansson
b879412a6f Upgrade to beta! 2023-02-21 15:31:28 +01:00
David Heinemeier Hansson
e678775a18 Merge pull request #54 from intrip/print-logs-for-healthcheck-status-mistmatch
Print container logs when HealthCheck response_code != 200
2023-02-21 14:34:46 +01:00
Jacopo
689b81014b Print container logs when HealthCheck response_code != 200
The Healthcheck container is shut down right after performing the check, this
makes it harder to troubleshoot configuration issues in the healthcheck
endpoint, e.g DNS rebinding error. Printing the container logs helps the troubleshooting.
2023-02-21 11:48:29 +01:00
David Heinemeier Hansson
01a4eecf98 Bump version for 0.8.1 2023-02-20 18:21:05 +01:00
David Heinemeier Hansson
6f7422af44 Merge pull request #53 from pagbrl/fix-env-concatenation
fix(escape-cli-args): Always use quotes to escape CLI arguments
2023-02-20 18:20:28 +01:00
David Heinemeier Hansson
1fccaf60b2 Cleanup escaping logic 2023-02-20 18:20:08 +01:00
David Heinemeier Hansson
9b02a7668d Merge branch 'main' into pr/53
* main:
  Bump version for 0.8.0
  Remove images of the same name before pulling a new one
  Changed to a timeout
  Better language
  Switch to ruby-based retry
2023-02-20 18:14:47 +01:00
David Heinemeier Hansson
f6ea287e66 Bump version for 0.8.0 2023-02-20 18:06:56 +01:00
David Heinemeier Hansson
42b343436d Remove images of the same name before pulling a new one
Or you'll end up with untagged dupes.
2023-02-20 18:06:16 +01:00
David Heinemeier Hansson
9d6ccf9889 Changed to a timeout 2023-02-20 17:59:41 +01:00
David Heinemeier Hansson
c4cc9e690b Better language 2023-02-20 17:44:55 +01:00
David Heinemeier Hansson
1ccf679ca9 Switch to ruby-based retry
Retry connection errors with backoff
2023-02-20 17:42:55 +01:00
Paul Gabriel
f81ba12aa5 fix(escape): Escape double quotes and all other characters reliably 2023-02-20 16:49:47 +01:00
Paul Gabriel
25e8b91569 fix(escape-cli-args): Always use quotes to escape CLI arguments 2023-02-20 15:02:34 +01:00
Paul Gabriel
21c6a1f1ba chore(rebase): Rebase main 2023-02-20 10:27:51 +01:00
David Heinemeier Hansson
5898fdd8f4 Expand arguments to be more self-explanatory in logs 2023-02-19 18:11:06 +01:00
David Heinemeier Hansson
5299826146 Alphabetical order 2023-02-19 17:43:56 +01:00
David Heinemeier Hansson
28be8dc0f0 Encourage registry password from ENV 2023-02-19 17:42:30 +01:00
David Heinemeier Hansson
2ed3ccc53e More readable tests 2023-02-19 17:40:41 +01:00
David Heinemeier Hansson
11c726858d Point to where secrets are from 2023-02-19 17:34:49 +01:00
David Heinemeier Hansson
8706fae2b5 Reveal all options in default config 2023-02-19 17:34:06 +01:00
David Heinemeier Hansson
67d6c3acfe Think we can drop this
Now that we rescue at the top level
2023-02-19 17:33:54 +01:00
David Heinemeier Hansson
a5fd4c76ba No need for invocation 2023-02-19 17:22:03 +01:00
David Heinemeier Hansson
f3a5845501 Remember this 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
5356f31e2e Remove also removes accessories but requires confirmation 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
67cb89b9b9 Remove requires confirmation 2023-02-19 17:16:06 +01:00
David Heinemeier Hansson
745b09051e Test app remove 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
0fa70f4688 Stop app before removing it 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
6bc2def677 No need for invoke
No double action possible
2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
42bc691758 CLI doc updates
Match word

Language

Suggest what accessories are

There are also accessories

Default already shown

Better example

Warn about secrets being shown

Now also accessories

Wording

Clarifications

Clarify how to see options

General option for all

Options important here too

Hide subcommands

Implied

Simpler as just version

Be concise

Missing word

Wordsmith

Simpler and uniform words are better

Clarify what exactly we're manipulating

Wordsmithing

Implicit

Simpler language

Hide subcommands

Clarify its container management

Just one per server

Simpler
2023-02-19 17:15:44 +01:00
David Heinemeier Hansson
e5c4cb0344 Retry healthcheck for up to 10 seconds (in case container wasnt ready) 2023-02-19 15:34:36 +01:00
David Heinemeier Hansson
a0d71f3fe4 Protect against missing current version 2023-02-19 09:48:35 +01:00
David Heinemeier Hansson
389ce2f701 Only output if there's a failure 2023-02-19 09:36:04 +01:00
David Heinemeier Hansson
8e918b1906 Output logs when healthcheck fails 2023-02-19 09:33:49 +01:00
David Heinemeier Hansson
e37e5f7d09 Bump version for 0.7.2 2023-02-18 18:23:28 +01:00
David Heinemeier Hansson
7f1191bf59 Change broadcast cmd to just take an argument instead of STDIN
Simpler
2023-02-18 18:22:46 +01:00
David Heinemeier Hansson
0c03216fdf Bump version for 0.7.1 2023-02-18 16:33:28 +01:00
David Heinemeier Hansson
1973f55c58 Don't include recorded_at with broadcast line
Receiving end will already add that
2023-02-18 16:33:12 +01:00
David Heinemeier Hansson
0a51cd0899 Update for healthcheck config 2023-02-18 16:28:31 +01:00
David Heinemeier Hansson
4b0a8728f1 Bump version for 0.7.0 2023-02-18 16:27:08 +01:00
David Heinemeier Hansson
3075f8daf1 Include healthcheck in config 2023-02-18 16:26:23 +01:00
David Heinemeier Hansson
9985834bd6 Use number 2023-02-18 16:26:17 +01:00
David Heinemeier Hansson
94b4461c76 Merge pull request #52 from mrsked/health-check-with-deploy
Add healthcheck before deploy
2023-02-18 16:24:41 +01:00
David Heinemeier Hansson
7afa9e0815 Mention healthcheck as part of steps instead 2023-02-18 16:23:46 +01:00
David Heinemeier Hansson
933ece35ab Add healthcheck before deploy 2023-02-18 16:22:08 +01:00
David Heinemeier Hansson
2f80b300f0 Test rolling back to a good version too 2023-02-18 14:55:11 +01:00
David Heinemeier Hansson
2e06bf59a4 Protect against rolling back to a bad version 2023-02-18 14:33:47 +01:00
David Heinemeier Hansson
854795c2b6 Wording 2023-02-18 12:10:42 +01:00
David Heinemeier Hansson
4fe7fb705a Use same sentence style as broadcasts for audit log lines 2023-02-18 12:00:15 +01:00
David Heinemeier Hansson
270e0d0e2c Merge pull request #50 from pagbrl/labels-traefik-docs
docs(traefik-labels): Improve docs for traefik labels formatting
2023-02-18 11:42:43 +01:00
David Heinemeier Hansson
6ddc9cf017 Merge pull request #51 from mrsked/audit-broadcasts
Add audit broadcasts
2023-02-18 11:41:19 +01:00
David Heinemeier Hansson
2dcd76b2de Merge branch 'main' into audit-broadcasts
* main:
  Remove unnecessary audit recordings
2023-02-18 11:38:34 +01:00
David Heinemeier Hansson
a6eabd0b67 Remove unnecessary audit recordings 2023-02-18 11:36:52 +01:00
David Heinemeier Hansson
fb9357b5ba Add audit broadcasts 2023-02-18 11:36:30 +01:00
Paul Gabriel
d484cfcc31 docs(traefik-labels): Improve docs for traefik labels formatting 2023-02-18 00:25:30 +01:00
David Heinemeier Hansson
5c93642f2a Prepare for custom pruning 2023-02-15 20:34:08 +01:00
David Heinemeier Hansson
8ff206ba7e Highlight 2023-02-15 18:08:46 +01:00
David Heinemeier Hansson
e36a5e111c Make a note about the /up requirement 2023-02-15 18:08:26 +01:00
David Heinemeier Hansson
72522001e5 Merge pull request #46 from fschueller/fix-prune-desc
Adjust CLI description for prune command to mention 7 days
2023-02-15 14:09:06 +01:00
David Heinemeier Hansson
50c4bb83cb Bump version for 0.6.4 2023-02-15 13:48:10 +01:00
David Heinemeier Hansson
b2875ad056 More readable tests 2023-02-15 13:47:16 +01:00
David Heinemeier Hansson
8ec94f105c Tag images with service label so we can prune exclusively 2023-02-15 13:41:03 +01:00
David Heinemeier Hansson
90f4212a68 Stray copypasta 2023-02-15 13:39:53 +01:00
David Heinemeier Hansson
648894f9a9 No need for quoting 2023-02-15 13:32:59 +01:00
David Heinemeier Hansson
dc68639dfa Prune all unused images matching time filter 2023-02-15 13:32:50 +01:00
David Heinemeier Hansson
244cf8b3b7 Add prune command test 2023-02-15 13:30:31 +01:00
David Heinemeier Hansson
f25f506d77 Don't use abbreviations when we don't have to 2023-02-15 13:26:57 +01:00
David Heinemeier Hansson
c29a177a7a DRY the use of build options into one call 2023-02-15 13:23:14 +01:00
Farah Schüller
03328a998c Adjust CLI description for prune command to mention 7 days 2023-02-14 17:05:36 +01:00
David Heinemeier Hansson
ec5fad5bea Describe the vision 2023-02-11 14:30:23 +01:00
48 changed files with 876 additions and 293 deletions

View File

@@ -6,3 +6,5 @@ gemspec
gem "debug" gem "debug"
gem "mocha" gem "mocha"
gem "railties" gem "railties"
gem "ed25519"
gem "bcrypt_pbkdf"

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.6.3) mrsk (0.8.2)
activesupport (>= 7.0) activesupport (>= 7.0)
dotenv (~> 2.8) dotenv (~> 2.8)
sshkit (~> 1.21) sshkit (~> 1.21)
@@ -29,6 +29,7 @@ GEM
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
bcrypt_pbkdf (1.1.0)
builder (3.2.4) builder (3.2.4)
concurrent-ruby (1.1.10) concurrent-ruby (1.1.10)
crass (1.0.6) crass (1.0.6)
@@ -36,6 +37,7 @@ GEM
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
dotenv (2.8.1) dotenv (2.8.1)
ed25519 (1.3.0)
erubi (1.12.0) erubi (1.12.0)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -96,7 +98,9 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
bcrypt_pbkdf
debug debug
ed25519
mocha mocha
mrsk! mrsk!
railties railties

View File

@@ -1,6 +1,6 @@
# MRSK # MRSK
MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be bundled with Docker.
## Installation ## Installation
@@ -14,7 +14,8 @@ servers:
- 192.168.0.2 - 192.168.0.2
registry: registry:
username: registry-user-name username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %> password:
- MRSK_REGISTRY_PASSWORD
env: env:
secret: secret:
- RAILS_MASTER_KEY - RAILS_MASTER_KEY
@@ -37,12 +38,23 @@ This will:
5. Push the image to the registry. 5. Push the image to the registry.
6. Pull the image from the registry on the servers. 6. Pull the image from the registry on the servers.
7. Ensure Traefik is running and accepting traffic on port 80. 7. Ensure Traefik is running and accepting traffic on port 80.
8. Stop any containers running a previous versions of the app. 8. Ensure your app responds with `200 OK` to `GET /up`.
9. Start a new container with the version of the app that matches the current git version hash. 9. Stop any containers running a previous versions of the app.
10. Prune unused images and stopped containers to ensure servers don't fill up. 10. Start a new container with the version of the app that matches the current git version hash.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Vision
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on our own hardware, or even just have a clear migration path to do so, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This structure also gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there's a lot of compelling options available.
Ultimately, MRSK is meant to compress the complexity of going to production using open source tooling that isn't tied to any commercial offering. Not to zero, though. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but from an early stage when those concepts are familiar.
## Why not just run Capistrano, Kubernetes or Docker Swarm? ## Why not just run Capistrano, Kubernetes or Docker Swarm?
MRSK basically is Capistrano for Containers, which allow us to use vanilla servers as the hosts. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the deploy servers of MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also allows for quicker deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection. MRSK basically is Capistrano for Containers, which allow us to use vanilla servers as the hosts. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the deploy servers of MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also allows for quicker deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection.
@@ -173,10 +185,10 @@ You can specialize the default Traefik rules by setting labels on the containers
``` ```
labels: labels:
traefik.http.routers.hey.rule: '''Host(`app.hey.com`)''' traefik.http.routers.hey.rule: Host(\`app.hey.com\`)
``` ```
Note: The extra quotes are needed to ensure the rule is passed in correctly! Note: The escaped backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash!
This allows you to run multiple applications on the same server sharing the same Traefik instance and port. This allows you to run multiple applications on the same server sharing the same Traefik instance and port.
See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules. See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules.
@@ -335,6 +347,40 @@ 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`.
### 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
```
### Using custom healthcheck path or port
MRSK defaults to checking the health of your application again `/up` on port 3000. You can tailor both with the `healthcheck` setting:
```yaml
healthcheck:
path: /healthz
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.
## Commands ## Commands
### Running commands on servers ### Running commands on servers
@@ -402,7 +448,7 @@ mrsk app exec -i 'bin/rails console'
``` ```
### Running details to see state of containers ### Running details to show state of containers
You can see the state of your servers by running `mrsk details`: You can see the state of your servers by running `mrsk details`:
@@ -452,7 +498,7 @@ If you wish to remove the entire application, including Traefik, containers, ima
## Stage of development ## Stage of development
This is alpha software. Lots of stuff is missing. Lots of stuff will keep moving around for a while. This is beta software. Commands may still move around. But we're live in production at [37signals](https://37signals.com).
## License ## License

View File

@@ -1,5 +1,5 @@
class Mrsk::Cli::Accessory < Mrsk::Cli::Base class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)" desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)"
def boot(name) def boot(name)
if name == "all" if name == "all"
MRSK.accessory_names.each { |accessory_name| boot(accessory_name) } MRSK.accessory_names.each { |accessory_name| boot(accessory_name) }
@@ -9,19 +9,19 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
upload(name) upload(name)
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} boot"), 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
desc "upload [NAME]", "Upload accessory files to host" desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name) def upload(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} upload files"), verbosity: :debug
accessory.files.each do |(local, remote)| accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local) accessory.ensure_local_file_present(local)
@@ -33,12 +33,10 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "directories [NAME]", "Create accessory directories on host" desc "directories [NAME]", "Create accessory directories on host", hide: true
def directories(name) def directories(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} create directories"), verbosity: :debug
accessory.directories.keys.each do |host_path| accessory.directories.keys.each do |host_path|
execute *accessory.make_directory(host_path) execute *accessory.make_directory(host_path)
end end
@@ -46,7 +44,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "reboot [NAME]", "Reboot accessory on host (stop container, remove container, start new container)" desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container)"
def reboot(name) def reboot(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
stop(name) stop(name)
@@ -55,27 +53,27 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "start [NAME]", "Start existing accessory on host" desc "start [NAME]", "Start existing accessory container on host"
def start(name) def start(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} start"), verbosity: :debug execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start execute *accessory.start
end end
end end
end end
desc "stop [NAME]", "Stop accessory on host" desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name) def stop(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} stop"), verbosity: :debug execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false execute *accessory.stop, raise_on_non_zero_exit: false
end end
end end
end end
desc "restart [NAME]", "Restart accessory on host" desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name) def restart(name)
with_accessory(name) do with_accessory(name) do
stop(name) stop(name)
@@ -83,7 +81,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "details [NAME]", "Display details about accessory on host (use NAME=all to boot all accessories)" desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)"
def details(name) def details(name)
if name == "all" if name == "all"
MRSK.accessory_names.each { |accessory_name| details(accessory_name) } MRSK.accessory_names.each { |accessory_name| details(accessory_name) }
@@ -94,7 +92,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "exec [NAME] [CMD]", "Execute a custom command on servers" desc "exec [NAME] [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(name, cmd) def exec(name, cmd)
@@ -111,21 +109,21 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
when options[:reuse] when options[:reuse]
say "Launching command from existing container...", :magenta say "Launching command from existing container...", :magenta
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd)) capture_with_info(*accessory.execute_in_existing_container(cmd))
end end
else else
say "Launching command from new container...", :magenta say "Launching command from new container...", :magenta
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd)) capture_with_info(*accessory.execute_in_new_container(cmd))
end end
end end
end end
end end
desc "logs [NAME]", "Show log lines from accessory on host" desc "logs [NAME]", "Show log lines from accessory on host (use --help to show options)"
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
@@ -151,11 +149,15 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to boot all accessories)" desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to remove all accessories)"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove(name) def remove(name)
if name == "all" if name == "all"
if options[:confirmed] || ask("This will remove all containers and images for all accessories. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) } MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
end
else else
if options[:confirmed] || ask("This will remove all containers and images for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
with_accessory(name) do with_accessory(name) do
stop(name) stop(name)
remove_container(name) remove_container(name)
@@ -164,32 +166,32 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
end end
end
desc "remove_container [NAME]", "Remove accessory container from host" desc "remove_container [NAME]", "Remove accessory container from host", hide: true
def remove_container(name) def remove_container(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove container"), verbosity: :debug execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container execute *accessory.remove_container
end end
end end
end end
desc "remove_image [NAME]", "Remove accessory image from host" desc "remove_image [NAME]", "Remove accessory image from host", hide: true
def remove_image(name) def remove_image(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove image"), verbosity: :debug execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug
execute *accessory.remove_image execute *accessory.remove_image
end end
end end
end end
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host" desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true
def remove_service_directory(name) def remove_service_directory(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) do on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove service directory"), verbosity: :debug
execute *accessory.remove_service_directory execute *accessory.remove_service_directory
end end
end end

View File

@@ -5,18 +5,27 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
using_version(options[:version] || most_recent_version_available) do |version| using_version(options[:version] || most_recent_version_available) do |version|
say "Start container with version #{version} (or reboot if already running)...", :magenta say "Start container with version #{version} (or reboot if already running)...", :magenta
cli = self
MRSK.config.roles.each do |role| MRSK.config.roles.each do |role|
on(role.hosts) do |host| on(role.hosts) do |host|
execute *MRSK.auditor.record("app boot version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Booted app version #{version}"), verbosity: :debug
begin begin
execute *MRSK.app.stop, raise_on_non_zero_exit: false old_version = capture_with_info(*MRSK.app.current_running_version).strip
execute *MRSK.app.run(role: role.name) execute *MRSK.app.run(role: role.name)
cli.say "Waiting #{MRSK.config.readiness_delay}s for app to boot...", :magenta
sleep MRSK.config.readiness_delay
execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/ if e.message =~ /already in use/
error "Rebooting container with same version already deployed on #{host}" error "Rebooting container with same version #{version} already deployed on #{host} (may cause gap in zero-downtime promise!)"
execute *MRSK.auditor.record("app rebooted with version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Rebooted app version #{version}"), verbosity: :debug
execute *MRSK.app.stop(version: version)
execute *MRSK.app.remove_container(version: version) execute *MRSK.app.remove_container(version: version)
execute *MRSK.app.run(role: role.name) execute *MRSK.app.run(role: role.name)
else else
@@ -28,28 +37,29 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
end end
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)" desc "start", "Start existing app container on servers"
def start def start
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("app start version #{MRSK.version}"), verbosity: :debug execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug
execute *MRSK.app.start, raise_on_non_zero_exit: false execute *MRSK.app.start, raise_on_non_zero_exit: false
end end
end end
desc "stop", "Stop app on servers" desc "stop", "Stop app container on servers"
def stop def stop
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("app stop"), verbosity: :debug execute *MRSK.auditor.record("Stopped app"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false execute *MRSK.app.stop, raise_on_non_zero_exit: false
end end
end end
desc "details", "Display details about app containers" # FIXME: Drop in favor of just containers?
desc "details", "Show details about app containers"
def details def details
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) }
end end
desc "exec [CMD]", "Execute a custom command on servers" desc "exec [CMD]", "Execute a custom command on servers (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(cmd) def exec(cmd)
@@ -74,7 +84,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
say "Launching command with version #{version} from existing container...", :magenta say "Launching command with version #{version} from existing container...", :magenta
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_existing_container(cmd)) puts_by_host host, capture_with_info(*MRSK.app.execute_in_existing_container(cmd))
end end
end end
@@ -84,28 +94,28 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
using_version(options[:version] || most_recent_version_available) do |version| using_version(options[:version] || most_recent_version_available) do |version|
say "Launching command with version #{version} from new container...", :magenta say "Launching command with version #{version} from new container...", :magenta
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd)) puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd))
end end
end end
end end
end end
desc "containers", "List all the app containers currently on servers" desc "containers", "Show app containers on servers"
def containers def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end end
desc "images", "List all the app images currently on servers" desc "images", "Show app images on servers"
def images def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end end
desc "logs", "Show lines from app on servers" desc "logs", "Show log lines from app on servers (use --help to show options)"
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
def logs def logs
# FIXME: Catch when app containers aren't running # FIXME: Catch when app containers aren't running
@@ -133,36 +143,37 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove", "Remove app containers and images from servers" desc "remove", "Remove app containers and images from servers"
def remove def remove
stop
remove_containers remove_containers
remove_images remove_images
end end
desc "remove_container [VERSION]", "Remove app container with given version from servers" desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version) def remove_container(version)
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove container #{version}"), verbosity: :debug execute *MRSK.auditor.record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version) execute *MRSK.app.remove_container(version: version)
end end
end end
desc "remove_containers", "Remove all app containers from servers" desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers def remove_containers
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove containers"), verbosity: :debug execute *MRSK.auditor.record("Removed all app containers"), verbosity: :debug
execute *MRSK.app.remove_containers execute *MRSK.app.remove_containers
end end
end end
desc "remove_images", "Remove all app images from servers" desc "remove_images", "Remove all app images from servers", hide: true
def remove_images def remove_images
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove images"), verbosity: :debug execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images execute *MRSK.app.remove_images
end end
end end
desc "current_version", "Shows the version currently running" desc "version", "Show app version currently running on servers"
def current_version def version
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip }
end end
@@ -184,8 +195,13 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
def most_recent_version_available(host: MRSK.primary_host) def most_recent_version_available(host: MRSK.primary_host)
version = nil version = nil
on(host) { version = capture_with_info(*MRSK.app.most_recent_version_from_available_images).strip } on(host) { version = capture_with_info(*MRSK.app.most_recent_version_from_available_images).strip }
if version == "<none>"
raise "Most recent image available was not tagged with a version (returned <none>)"
else
version.presence version.presence
end end
end
def current_running_version(host: MRSK.primary_host) def current_running_version(host: MRSK.primary_host)
version = nil version = nil

View File

@@ -17,8 +17,10 @@ module Mrsk::Cli
class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)" class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)"
class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)" class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)"
class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file (default: config/deploy.yml)" class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (west -> deploy.west.yml)" class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
class_option :skip_broadcast, aliases: "-B", type: :boolean, default: false, desc: "Skip audit broadcasts"
def initialize(*) def initialize(*)
super super
@@ -59,9 +61,14 @@ module Mrsk::Cli
def print_runtime def print_runtime
started_at = Time.now started_at = Time.now
yield yield
return Time.now - started_at
ensure ensure
runtime = Time.now - started_at runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}" puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
end end
end end

View File

@@ -1,11 +1,11 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Deliver a newly built app image to servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
invoke :push push
invoke :pull pull
end end
desc "push", "Build locally and push app image to registry" desc "push", "Build and push app image to registry"
def push def push
cli = self cli = self
@@ -26,15 +26,16 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end end
end end
desc "pull", "Pull app image from the registry onto servers" desc "pull", "Pull app image from registry onto servers"
def pull def pull
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.auditor.record("build pull image #{MRSK.version}"), verbosity: :debug execute *MRSK.auditor.record("Pulled image with version #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.clean, raise_on_non_zero_exit: false
execute *MRSK.builder.pull execute *MRSK.builder.pull
end end
end end
desc "create", "Create a local build setup" desc "create", "Create a build setup"
def create def create
run_locally do run_locally do
begin begin
@@ -51,7 +52,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end end
end end
desc "remove", "Remove local build setup" desc "remove", "Remove build setup"
def remove def remove
run_locally do run_locally do
debug "Using builder: #{MRSK.builder.name}" debug "Using builder: #{MRSK.builder.name}"
@@ -59,7 +60,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end end
end end
desc "details", "Show the name of the configured builder" desc "details", "Show build setup"
def details def details
run_locally do run_locally do
puts "Builder: #{MRSK.builder.name}" puts "Builder: #{MRSK.builder.name}"

View File

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

View File

@@ -1,5 +1,5 @@
class Mrsk::Cli::Main < Mrsk::Cli::Base class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy the app to servers" desc "setup", "Setup all accessories and deploy app to servers"
def setup def setup
print_runtime do print_runtime do
invoke "mrsk:cli:server:bootstrap" invoke "mrsk:cli:server:bootstrap"
@@ -8,9 +8,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end end
end end
desc "deploy", "Deploy the app to servers" desc "deploy", "Deploy app to servers"
def deploy def deploy
print_runtime do runtime = print_runtime do
say "Ensure Docker is installed...", :magenta say "Ensure Docker is installed...", :magenta
invoke "mrsk:cli:server:bootstrap" invoke "mrsk:cli:server:bootstrap"
@@ -23,37 +23,60 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
say "Ensure Traefik is running...", :magenta say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot" invoke "mrsk:cli:traefik:boot"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all" invoke "mrsk:cli:prune:all"
end end
audit_broadcast "Deployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
end end
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)" desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
def redeploy def redeploy
print_runtime do runtime = print_runtime do
say "Build and push app image...", :magenta say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:build:deliver"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
end end
audit_broadcast "Redeployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
end end
desc "rollback [VERSION]", "Rollback the app to VERSION" desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version) def rollback(version)
MRSK.version = version MRSK.version = version
if container_name_available?(MRSK.config.service_with_version)
say "Start version #{version}, then stop the old version...", :magenta
cli = self cli = self
cli.say "Stop current version, then start version #{version}...", :magenta on(MRSK.hosts) do |host|
on(MRSK.hosts) do old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start execute *MRSK.app.start
cli.say "Waiting #{MRSK.config.readiness_delay}s for app to start...", :magenta
sleep MRSK.config.readiness_delay
execute *MRSK.app.stop(version: old_version), raise_on_non_zero_exit: false
end
audit_broadcast "Rolled back app to version #{version}" unless options[:skip_broadcast]
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end end
end end
desc "details", "Display details about Traefik and app containers" desc "details", "Show details about all containers"
def details def details
invoke "mrsk:cli:traefik:details" invoke "mrsk:cli:traefik:details"
invoke "mrsk:cli:app:details" invoke "mrsk:cli:app:details"
@@ -67,7 +90,7 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end end
end end
desc "config", "Show combined config" desc "config", "Show combined config (including secrets!)"
def config def config
run_locally do run_locally do
puts MRSK.config.to_h.to_yaml puts MRSK.config.to_h.to_yaml
@@ -117,36 +140,56 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600) File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)
end end
desc "remove", "Remove Traefik, app, and registry session from servers" desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove def remove
invoke "mrsk:cli:traefik:remove" if options[:confirmed] || ask(remove_confirmation_question, limited_to: %w( y N ), default: "N") == "y"
invoke "mrsk:cli:app:remove" invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:registry:logout" invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
invoke "mrsk:cli:accessory:remove", [ "all" ]
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed)
end
end end
desc "version", "Display the MRSK version" desc "version", "Show MRSK version"
def version def version
puts Mrsk::VERSION puts Mrsk::VERSION
end end
desc "accessory", "Manage the accessories" desc "accessory", "Manage accessories (db/redis/search)"
subcommand "accessory", Mrsk::Cli::Accessory subcommand "accessory", Mrsk::Cli::Accessory
desc "app", "Manage the application" desc "app", "Manage application"
subcommand "app", Mrsk::Cli::App subcommand "app", Mrsk::Cli::App
desc "build", "Build the application image" desc "build", "Build application image"
subcommand "build", Mrsk::Cli::Build subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "prune", "Prune old application images and containers" desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune subcommand "prune", Mrsk::Cli::Prune
desc "registry", "Login and out of the image registry" desc "registry", "Login and -out of the image registry"
subcommand "registry", Mrsk::Cli::Registry subcommand "registry", Mrsk::Cli::Registry
desc "server", "Bootstrap servers with Docker" desc "server", "Bootstrap servers with Docker"
subcommand "server", Mrsk::Cli::Server subcommand "server", Mrsk::Cli::Server
desc "traefik", "Manage the Traefik load balancer" desc "traefik", "Manage Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik subcommand "traefik", Mrsk::Cli::Traefik
private
def container_name_available?(container_name, host: MRSK.primary_host)
container_names = nil
on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") }
Array(container_names).include?(container_name)
end
def remove_confirmation_question
"This will remove all containers and images. " +
(MRSK.config.accessories.any? ? "Including #{MRSK.config.accessories.collect(&:name).to_sentence}. " : "") +
"Are you sure?"
end
end end

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure Docker is installed on the servers" desc "bootstrap", "Ensure Docker is installed on servers"
def bootstrap def bootstrap
on(MRSK.hosts + MRSK.accessory_hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" } on(MRSK.hosts + MRSK.accessory_hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" }
end end

View File

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

View File

@@ -6,34 +6,34 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)" desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)"
def reboot def reboot
invoke :stop stop
invoke :remove_container remove_container
invoke :boot boot
end end
desc "start", "Start existing Traefik on servers" desc "start", "Start existing Traefik container on servers"
def start def start
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik start"), 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
end end
end end
desc "stop", "Stop Traefik on servers" desc "stop", "Stop existing Traefik container on servers"
def stop def stop
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik stop"), 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
end end
end end
desc "restart", "Restart Traefik on servers" desc "restart", "Restart existing Traefik container on servers"
def restart def restart
invoke :stop stop
invoke :start start
end end
desc "details", "Display details about Traefik containers from servers" desc "details", "Show details about Traefik container from servers"
def details def details
on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" } on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" }
end end
@@ -64,23 +64,23 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove", "Remove Traefik container and image from servers" desc "remove", "Remove Traefik container and image from servers"
def remove def remove
invoke :stop stop
invoke :remove_container remove_container
invoke :remove_image remove_image
end end
desc "remove_container", "Remove Traefik container from servers" desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container def remove_container
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove container"), verbosity: :debug execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container execute *MRSK.traefik.remove_container
end end
end end
desc "remove_container", "Remove Traefik image from servers" desc "remove_container", "Remove Traefik image from servers", hide: true
def remove_image def remove_image
on(MRSK.traefik_hosts) do on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove image"), verbosity: :debug execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image execute *MRSK.traefik.remove_image
end end
end end

View File

@@ -49,22 +49,6 @@ class Mrsk::Commander
@app ||= Mrsk::Commands::App.new(config) @app ||= Mrsk::Commands::App.new(config)
end end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def accessory(name) def accessory(name)
Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end end
@@ -73,6 +57,26 @@ class Mrsk::Commander
@auditor ||= Mrsk::Commands::Auditor.new(config) @auditor ||= Mrsk::Commands::Auditor.new(config)
end end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def with_verbosity(level) def with_verbosity(level)
old_level = self.verbosity old_level = self.verbosity

View File

@@ -10,10 +10,10 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def run def run
docker :run, docker :run,
"--name", service_name, "--name", service_name,
"-d", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", "--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p", port, "--publish", port,
*env_args, *env_args,
*volume_args, *volume_args,
*label_args, *label_args,
@@ -35,14 +35,14 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def follow_logs(grep: nil) def follow_logs(grep: nil)
run_over_ssh \ run_over_ssh \
pipe \ pipe \
docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"), docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
end end
@@ -96,11 +96,11 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def remove_container def remove_container
docker :container, :prune, "-f", *service_filter docker :container, :prune, "--force", *service_filter
end end
def remove_image def remove_image
docker :image, :prune, "-a", "-f", *service_filter docker :image, :prune, "--all", "--force", *service_filter
end end
private private

View File

@@ -3,7 +3,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
role = config.role(role) role = config.role(role)
docker :run, docker :run,
"-d", "--detach",
"--restart unless-stopped", "--restart unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", "--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--name", service_with_version, "--name", service_with_version,
@@ -18,8 +18,10 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :start, service_with_version docker :start, service_with_version
end end
def stop def stop(version: nil)
pipe current_container_id, xargs(docker(:stop)) pipe \
version ? container_id_for_version(version) : current_container_id,
xargs(docker(:stop))
end end
def info def info
@@ -30,7 +32,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_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" -n #{lines}" if lines} 2>&1", "xargs docker logs#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
@@ -38,7 +40,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
run_over_ssh \ run_over_ssh \
pipe( pipe(
current_container_id, current_container_id,
"xargs docker logs -t -n 10 -f 2>&1", "xargs docker logs --timestamps --tail 10 --follow 2>&1",
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
), ),
host: host host: host
@@ -72,11 +74,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
def current_container_id def current_container_id
docker :ps, "-q", *service_filter docker :ps, "--quiet", *service_filter
end
def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q"
end end
def current_running_version def current_running_version
@@ -93,9 +91,19 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
"head -n 1" "head -n 1"
end end
def all_versions_from_available_containers
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end
def list_containers def list_containers
docker :container, :ls, "-a", *service_filter docker :container, :ls, "--all", *service_filter
end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
end end
def remove_container(version:) def remove_container(version:)
@@ -105,7 +113,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def remove_containers def remove_containers
docker :container, :prune, "-f", *service_filter docker :container, :prune, "--force", *service_filter
end end
def list_images def list_images
@@ -113,7 +121,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def remove_images def remove_images
docker :image, :prune, "-a", "-f", *service_filter docker :image, :prune, "--all", "--force", *service_filter
end end
@@ -126,6 +134,10 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
end end
def container_id_for_version(version)
container_id_for(container_name: service_with_version(version))
end
def service_filter def service_filter
[ "--filter", "label=service=#{config.service}" ] [ "--filter", "label=service=#{config.service}" ]
end end

View File

@@ -1,12 +1,20 @@
require "active_support/core_ext/time/conversions" require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base class Mrsk::Commands::Auditor < Mrsk::Commands::Base
# Runs remotely
def record(line) def record(line)
append \ append \
[ :echo, tagged_line(line) ], [ :echo, tagged_record_line(line) ],
audit_log_file audit_log_file
end end
# Runs locally
def broadcast(line)
if broadcast_cmd = config.audit_broadcast_cmd
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
def reveal def reveal
[ :tail, "-n", 50, audit_log_file ] [ :tail, "-n", 50, audit_log_file ]
end end
@@ -16,19 +24,19 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
"mrsk-#{config.service}-audit.log" "mrsk-#{config.service}-audit.log"
end end
def tagged_line(line) def tagged_record_line(line)
"'#{tags} #{line}'" "'#{recorded_at_tag} #{performer_tag} #{line}'"
end end
def tags def tagged_broadcast_line(line)
"[#{timestamp}] [#{performer}]" "'#{performer_tag} #{line}'"
end end
def performer def performer_tag
`whoami`.strip "[#{`whoami`.strip}]"
end end
def timestamp def recorded_at_tag
Time.now.to_fs(:db) "[#{Time.now.to_fs(:db)}]"
end end
end end

View File

@@ -17,6 +17,10 @@ module Mrsk::Commands
end end
end end
def container_id_for(container_name:)
docker :container, :ls, "--all", "--filter", "name=#{container_name}", "--quiet"
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands

View File

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

View File

@@ -1,10 +1,27 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils delegate :argumentize, to: Mrsk::Utils
def clean
docker :image, :rm, "--force", config.absolute_image
end
def pull def pull
docker :pull, config.absolute_image docker :pull, config.absolute_image
end end
def build_options
[ *build_tags, *build_labels, *build_args, *build_secrets ]
end
private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
end
def build_labels
argumentize "--label", { service: config.service }
end
def build_args def build_args
argumentize "--build-arg", args, redacted: true argumentize "--build-arg", args, redacted: true
end end
@@ -13,11 +30,6 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] } argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end end
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
end
private
def args def args
(config.builder && config.builder["args"]) || {} (config.builder && config.builder["args"]) || {}
end end

View File

@@ -12,9 +12,7 @@ class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
"--push", "--push",
"--platform", "linux/amd64,linux/arm64", "--platform", "linux/amd64,linux/arm64",
"--builder", builder_name, "--builder", builder_name,
*build_tags, *build_options,
*build_args,
*build_secrets,
"." "."
end end

View File

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

View File

@@ -16,9 +16,7 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
"--push", "--push",
"--platform", platform, "--platform", platform,
"--builder", builder_name, "--builder", builder_name,
*build_tags, *build_options,
*build_args,
*build_secrets,
"." "."
end end

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
"-d", "--detach",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}", "--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p 80:80", "--publish", "80:80",
"-v /var/run/docker.sock:/var/run/docker.sock", "--volume", "/var/run/docker.sock:/var/run/docker.sock",
"traefik", "traefik",
"--providers.docker", "--providers.docker",
"--log.level=DEBUG", "--log.level=DEBUG",
@@ -26,23 +26,23 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, "traefik", (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def follow_logs(host:, grep: nil) def follow_logs(host:, grep: nil)
run_over_ssh pipe( run_over_ssh pipe(
docker(:logs, "traefik", "-t", "-n", "10", "-f", "2>&1"), docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep) (%(grep "#{grep}") if grep)
).join(" "), host: host ).join(" "), host: host
end end
def remove_container def remove_container
docker :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik" docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
def remove_image def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
private private

View File

@@ -107,6 +107,7 @@ class Mrsk::Configuration
end end
end end
def ssh_user def ssh_user
if raw_config.ssh.present? if raw_config.ssh.present?
raw_config.ssh["user"] || "root" raw_config.ssh["user"] || "root"
@@ -127,6 +128,18 @@ class Mrsk::Configuration
end end
def audit_broadcast_cmd
raw_config.audit_broadcast_cmd
end
def healthcheck
{ "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {})
end
def readiness_delay
raw_config.readiness_delay || 7
end
def valid? def valid?
ensure_required_keys_present && ensure_env_available ensure_required_keys_present && ensure_env_available
end end
@@ -145,7 +158,8 @@ class Mrsk::Configuration
volume_args: volume_args, volume_args: volume_args,
ssh_options: ssh_options, ssh_options: ssh_options,
builder: raw_config.builder, builder: raw_config.builder,
accessories: raw_config.accessories accessories: raw_config.accessories,
healthcheck: healthcheck
}.compact }.compact
end end

View File

@@ -58,10 +58,10 @@ class Mrsk::Configuration::Role
def traefik_labels def traefik_labels
if running_traefik? if running_traefik?
{ {
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'", "traefik.http.routers.#{config.service}.rule" => "PathPrefix(`/`)",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3", "traefik.http.middlewares.#{config.service}.retry.attempts" => "5",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" "traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms"
} }
else else

View File

@@ -1,13 +1,14 @@
module Mrsk::Utils module Mrsk::Utils
extend self extend self
# Return a list of shell arguments using the same named argument against the passed attributes (hash or array). # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).
def argumentize(argument, attributes, redacted: false) def argumentize(argument, attributes, redacted: false)
Array(attributes).flat_map do |k, v| Array(attributes).flat_map do |key, value|
if v.present? if value.present?
[ argument, redacted ? redact("#{k}=#{v}") : "#{k}=#{v}" ] escaped_pair = [ key, value.to_s.dump.gsub(/`/, '\\\\`') ].join("=")
[ argument, redacted ? redact(escaped_pair) : escaped_pair ]
else else
[ argument, k ] [ argument, key ]
end end
end end
end end

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.6.3" VERSION = "0.8.2"
end end

View File

@@ -14,7 +14,7 @@ class CliAccessoryTest < CliTestCase
end end
test "boot" do test "boot" do
assert_match "Running docker run --name app-mysql -d --restart unless-stopped --log-opt max-size=10m -p 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=% --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=app-mysql mysql:5.7 on 1.1.1.3", run_command("boot", "mysql") assert_match "Running docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=\"%\" --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" mysql:5.7 on 1.1.1.3", run_command("boot", "mysql")
end end
test "exec" do test "exec" do
@@ -31,6 +31,14 @@ class CliAccessoryTest < CliTestCase
end end
end end
test "remove with confirmation" do
run_command("remove", "mysql", "-y").tap do |output|
assert_match /docker container stop app-mysql/, output
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output
assert_match /rm -rf app-mysql/, output
end
end
private private
def run_command(*command) def run_command(*command)
stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) } stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }

View File

@@ -2,7 +2,16 @@ require_relative "cli_test_case"
class CliAppTest < CliTestCase class CliAppTest < CliTestCase
test "boot" do test "boot" do
assert_match /Running docker run -d --restart unless-stopped/, run_command("boot") # Stub current version fetch
SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.returns("999") # new version
.then
.returns("123") # old version
run_command("boot").tap do |output|
assert_match /docker run --detach --restart unless-stopped/, output
assert_match /docker container ls --all --filter name=app-123 --quiet | xargs docker stop/, output
end
end end
test "boot will reboot if same version is already running" do test "boot will reboot if same version is already running" do
@@ -11,12 +20,17 @@ class CliAppTest < 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
MRSK.app.stubs(:run).raises(SSHKit::Command::Failed.new("already in use")).then.returns([ :docker, :run ]) MRSK.app.stubs(:run)
.raises(SSHKit::Command::Failed.new("already in use"))
.then
.raises(SSHKit::Command::Failed.new("already in use"))
.then
.returns([ :docker, :run ])
run_command("boot").tap do |output| run_command("boot").tap do |output|
assert_match /Rebooting container with same version already deployed/, output # Can't start what's already running assert_match /Rebooting container with same version 999 already deployed/, output # Can't start what's already running
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output # Stop what's running assert_match /docker container ls --all --filter name=app-999 --quiet | xargs docker container rm/, output # Stop old running
assert_match /docker container ls -a -f name=app-999 -q \| xargs docker container rm/, output # Remove old container assert_match /docker container ls --all --filter name=app-999 --quiet | xargs docker container rm/, output # Remove old container
assert_match /docker run/, output # Start new container assert_match /docker run/, output # Start new container
end end
ensure ensure
@@ -31,7 +45,7 @@ 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 -q --filter label=service=app \| xargs docker stop/, output assert_match /docker ps --quiet --filter label=service=app \| xargs docker stop/, output
end end
end end
@@ -41,9 +55,17 @@ class CliAppTest < CliTestCase
end end
end end
test "remove" do
run_command("remove").tap do |output|
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output
assert_match /docker image prune --all --force --filter label=service=app/, output
end
end
test "remove_container" do test "remove_container" do
run_command("remove_container", "1234567").tap do |output| run_command("remove_container", "1234567").tap do |output|
assert_match /docker container ls -a -f name=app-1234567 -q \| xargs docker container rm/, output assert_match /docker container ls --all --filter name=app-1234567 --quiet \| xargs docker container rm/, output
end end
end end

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

@@ -0,0 +1,15 @@
require_relative "cli_test_case"
class CliBuildTest < CliTestCase
test "pull" do
run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull dhh\/app:999/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Build.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -5,4 +5,52 @@ class CliMainTest < CliTestCase
version = stdouted { Mrsk::Cli::Main.new.version } version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version assert_equal Mrsk::VERSION, version
end end
test "rollback bad version" do
run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output|
assert_match /docker container ls --all --filter label=service=app --format '{{ .Names }}'/, output
assert_match /The app version 'nonsense' is not available as a container/, output
end
end
test "rollback good version" do
Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true)
run_command("rollback", "123").tap do |output|
assert_match /Start version 123, then stop the old version/, output
assert_match /docker ps -q --filter label=service=app | xargs docker stop/, output
assert_match /docker start app-123/, output
end
end
test "remove with confirmation" do
run_command("remove", "-y").tap do |output|
assert_match /docker container stop traefik/, output
assert_match /docker container prune --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output
assert_match /docker image prune --all --force --filter label=service=app/, output
assert_match /docker container stop app-mysql/, output
assert_match /docker container prune --force --filter label=service=app-mysql/, output
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output
assert_match /rm -rf app-mysql/, output
assert_match /docker container stop app-redis/, output
assert_match /docker container prune --force --filter label=service=app-redis/, output
assert_match /docker image prune --all --force --filter label=service=app-redis/, output
assert_match /rm -rf app-redis/, output
assert_match /docker logout/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end end

View File

@@ -49,11 +49,11 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name app-mysql -d --restart unless-stopped --log-opt max-size=10m -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% --label service=app-mysql mysql:8.0", "docker run --name app-mysql --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" mysql:8.0",
@mysql.run.join(" ") @mysql.run.join(" ")
assert_equal \ assert_equal \
"docker run --name app-redis -d --restart unless-stopped --log-opt max-size=10m -p 6379:6379 -e SOMETHING=else --volume /var/lib/redis:/data --label service=app-redis --label cache=true redis:latest", "docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=10m --publish 6379:6379 -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
@redis.run.join(" ") @redis.run.join(" ")
end end
@@ -78,7 +78,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% mysql:8.0 mysql -u root", "docker run --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" mysql:8.0 mysql -u root",
@mysql.execute_in_new_container("mysql", "-u", "root").join(" ") @mysql.execute_in_new_container("mysql", "-u", "root").join(" ")
end end
@@ -90,7 +90,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container over ssh" do test "execute in new container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do @mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% mysql:8.0 mysql -u root|, assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" mysql:8.0 mysql -u root|,
@mysql.execute_in_new_container_over_ssh("mysql", "-u", "root") @mysql.execute_in_new_container_over_ssh("mysql", "-u", "root")
end end
end end
@@ -106,29 +106,29 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker logs app-mysql -t 2>&1", "docker logs app-mysql --timestamps 2>&1",
@mysql.logs.join(" ") @mysql.logs.join(" ")
assert_equal \ assert_equal \
"docker logs app-mysql --since 5m -n 100 -t 2>&1 | grep 'thing'", "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'",
@mysql.logs(since: "5m", lines: 100, grep: "thing").join(" ") @mysql.logs(since: "5m", lines: 100, grep: "thing").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.5 'docker logs app-mysql -t -n 10 -f 2>&1'", "ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
@mysql.follow_logs @mysql.follow_logs
end end
test "remove container" do test "remove container" do
assert_equal \ assert_equal \
"docker container prune -f --filter label=service=app-mysql", "docker container prune --force --filter label=service=app-mysql",
@mysql.remove_container.join(" ") @mysql.remove_container.join(" ")
end end
test "remove image" do test "remove image" do
assert_equal \ assert_equal \
"docker image prune -a -f --filter label=service=app-mysql", "docker image prune --all --force --filter label=service=app-mysql",
@mysql.remove_image.join(" ") @mysql.remove_image.join(" ")
end end
end end

View File

@@ -14,7 +14,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=456 --label service=app --label role=web --label traefik.http.routers.app.rule='PathPrefix(`/`)' --label traefik.http.services.app.loadbalancer.healthcheck.path=/up --label traefik.http.services.app.loadbalancer.healthcheck.interval=1s --label traefik.http.middlewares.app.retry.attempts=3 --label traefik.http.middlewares.app.retry.initialinterval=500ms dhh/app:999", "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app.retry.attempts=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999",
@app.run.join(" ") @app.run.join(" ")
end end
@@ -22,7 +22,15 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:volumes] = ["/local/path:/container/path" ] @config[:volumes] = ["/local/path:/container/path" ]
assert_equal \ assert_equal \
"docker run -d --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=456 --volume /local/path:/container/path --label service=app --label role=web --label traefik.http.routers.app.rule='PathPrefix(`/`)' --label traefik.http.services.app.loadbalancer.healthcheck.path=/up --label traefik.http.services.app.loadbalancer.healthcheck.interval=1s --label traefik.http.middlewares.app.retry.attempts=3 --label traefik.http.middlewares.app.retry.initialinterval=500ms dhh/app:999", "docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app.loadbalancer.healthcheck.path=\"/up\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app.retry.attempts=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999",
@app.run.join(" ")
end
test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"docker run --detach --restart unless-stopped --log-opt max-size=10m --name app-999 -e RAILS_MASTER_KEY=\"456\" --label service=\"app\" --label role=\"web\" --label traefik.http.routers.app.rule=\"PathPrefix(\\`/\\`)\" --label traefik.http.services.app.loadbalancer.healthcheck.path=\"/healthz\" --label traefik.http.services.app.loadbalancer.healthcheck.interval=\"1s\" --label traefik.http.middlewares.app.retry.attempts=\"5\" --label traefik.http.middlewares.app.retry.initialinterval=\"500ms\" dhh/app:999",
@app.run.join(" ") @app.run.join(" ")
end end
@@ -34,7 +42,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "stop" do test "stop" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker stop", "docker ps --quiet --filter label=service=app | xargs docker stop",
@app.stop.join(" ") @app.stop.join(" ")
end end
@@ -47,38 +55,38 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1", "docker ps --quiet --filter label=service=app | xargs docker logs 2>&1",
@app.logs.join(" ") @app.logs.join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1", "docker ps --quiet --filter label=service=app | xargs docker logs --since 5m 2>&1",
@app.logs(since: "5m").join(" ") @app.logs(since: "5m").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -n 100 2>&1", "docker ps --quiet --filter label=service=app | xargs docker logs --tail 100 2>&1",
@app.logs(lines: "100").join(" ") @app.logs(lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m -n 100 2>&1", "docker ps --quiet --filter label=service=app | xargs docker logs --since 5m --tail 100 2>&1",
@app.logs(since: "5m", lines: "100").join(" ") @app.logs(since: "5m", lines: "100").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app | xargs docker logs 2>&1 | grep 'my-id'",
@app.logs(grep: "my-id").join(" ") @app.logs(grep: "my-id").join(" ")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1 | grep 'my-id'", "docker ps --quiet --filter label=service=app | xargs docker logs --since 5m 2>&1 | grep 'my-id'",
@app.logs(since: "5m", grep: "my-id").join(" ") @app.logs(since: "5m", grep: "my-id").join(" ")
end end
test "follow logs" do test "follow logs" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do @app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1", "docker ps --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 2>&1",
@app.follow_logs(host: "app-1") @app.follow_logs(host: "app-1")
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1 | grep \"Completed\"", "docker ps --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 2>&1 | grep \"Completed\"",
@app.follow_logs(host: "app-1", grep: "Completed") @app.follow_logs(host: "app-1", grep: "Completed")
end end
end end
@@ -86,7 +94,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails db:setup", "docker run --rm -e RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails db:setup",
@app.execute_in_new_container("bin/rails", "db:setup").join(" ") @app.execute_in_new_container("bin/rails", "db:setup").join(" ")
end end
@@ -98,7 +106,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container over ssh" do test "execute in new container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do @app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails c|, assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c|,
@app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1") @app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end end
end end
@@ -137,13 +145,13 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_container_id" do test "current_container_id" do
assert_equal \ assert_equal \
"docker ps -q --filter label=service=app", "docker ps --quiet --filter label=service=app",
@app.current_container_id.join(" ") @app.current_container_id.join(" ")
end end
test "container_id_for" do test "container_id_for" do
assert_equal \ assert_equal \
"docker container ls -a -f name=app-999 -q", "docker container ls --all --filter name=app-999 --quiet",
@app.container_id_for(container_name: "app-999").join(" ") @app.container_id_for(container_name: "app-999").join(" ")
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ assert_equal \
"docker run --name traefik -d --restart unless-stopped --log-opt max-size=10m -p 80:80 -v /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format json --metrics.prometheus.buckets 0.1,0.3,1.2,5.0", "docker run --name traefik --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /var/run/docker.sock:/var/run/docker.sock traefik --providers.docker --log.level=DEBUG --accesslog.format json --metrics.prometheus.buckets 0.1,0.3,1.2,5.0",
new_command.run.join(" ") new_command.run.join(" ")
end end
@@ -34,49 +34,49 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "traefik logs" do test "traefik logs" do
assert_equal \ assert_equal \
"docker logs traefik -t 2>&1", "docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
end end
test "traefik logs since 2h" do test "traefik logs since 2h" do
assert_equal \ assert_equal \
"docker logs traefik --since 2h -t 2>&1", "docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ") new_command.logs(since: '2h').join(" ")
end end
test "traefik logs last 10 lines" do test "traefik logs last 10 lines" do
assert_equal \ assert_equal \
"docker logs traefik -n 10 -t 2>&1", "docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ") new_command.logs(lines: 10).join(" ")
end end
test "traefik logs with grep hello!" do test "traefik logs with grep hello!" do
assert_equal \ assert_equal \
"docker logs traefik -t 2>&1 | grep 'hello!'", "docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ") new_command.logs(grep: 'hello!').join(" ")
end end
test "traefik remove container" do test "traefik remove container" do
assert_equal \ assert_equal \
"docker container prune -f --filter label=org.opencontainers.image.title=Traefik", "docker container prune --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_container.join(" ") new_command.remove_container.join(" ")
end end
test "traefik remove image" do test "traefik remove image" do
assert_equal \ assert_equal \
"docker image prune -a -f --filter label=org.opencontainers.image.title=Traefik", "docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_image.join(" ") new_command.remove_image.join(" ")
end end
test "traefik follow logs" do test "traefik follow logs" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1'", "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1'",
new_command.follow_logs(host: @config[:servers].first) new_command.follow_logs(host: @config[:servers].first)
end end
test "traefik follow logs with grep hello!" do test "traefik follow logs with grep hello!" do
assert_equal \ assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1 | grep \"hello!\"'", "ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'",
new_command.follow_logs(host: @config[:servers].first, grep: 'hello!') new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end end

View File

@@ -72,20 +72,20 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end end
test "label args" do test "label args" do
assert_equal ["--label", "service=app-mysql"], @config.accessory(:mysql).label_args assert_equal ["--label", "service=\"app-mysql\""], @config.accessory(:mysql).label_args
assert_equal ["--label", "service=app-redis", "--label", "cache=true"], @config.accessory(:redis).label_args assert_equal ["--label", "service=\"app-redis\"", "--label", "cache=\"true\""], @config.accessory(:redis).label_args
end end
test "env args with secret" do test "env args with secret" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
assert_equal ["-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%"], @config.accessory(:mysql).env_args assert_equal ["-e", "MYSQL_ROOT_PASSWORD=\"secret123\"", "-e", "MYSQL_ROOT_HOST=\"%\""], @config.accessory(:mysql).env_args
assert @config.accessory(:mysql).env_args[1].is_a?(SSHKit::Redaction) assert @config.accessory(:mysql).env_args[1].is_a?(SSHKit::Redaction)
ensure ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil ENV["MYSQL_ROOT_PASSWORD"] = nil
end end
test "env args without secret" do test "env args without secret" do
assert_equal ["-e", "SOMETHING=else"], @config.accessory(:redis).env_args assert_equal ["-e", "SOMETHING=\"else\""], @config.accessory(:redis).env_args
end end
test "volume args" do test "volume args" do

View File

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

View File

@@ -89,7 +89,7 @@ class ConfigurationTest < ActiveSupport::TestCase
end end
test "env args" do test "env args" do
assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args assert_equal [ "-e", "REDIS_URL=\"redis://x/y\"" ], @config.env_args
end end
test "env args with clear and secrets" do test "env args with clear and secrets" do
@@ -98,7 +98,7 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "clear" => { "PORT" => "3000" }, "secret" => [ "PASSWORD" ] } env: { "clear" => { "PORT" => "3000" }, "secret" => [ "PASSWORD" ] }
}) }) }) })
assert_equal [ "-e", "PASSWORD=secret123", "-e", "PORT=3000" ], config.env_args assert_equal [ "-e", "PASSWORD=\"secret123\"", "-e", "PORT=\"3000\"" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction) assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
@@ -109,7 +109,7 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "clear" => { "PORT" => "3000" } } env: { "clear" => { "PORT" => "3000" } }
}) }) }) })
assert_equal [ "-e", "PORT=3000" ], config.env_args assert_equal [ "-e", "PORT=\"3000\"" ], config.env_args
end end
test "env args with only secrets" do test "env args with only secrets" do
@@ -118,7 +118,7 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "secret" => [ "PASSWORD" ] } env: { "secret" => [ "PASSWORD" ] }
}) }) }) })
assert_equal [ "-e", "PASSWORD=secret123" ], config.env_args assert_equal [ "-e", "PASSWORD=\"secret123\"" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction) assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure ensure
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
@@ -181,6 +181,6 @@ 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"] }, @config.to_h) assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=\"redis://x/y\""], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"], :healthcheck=>{"path"=>"/up", "port"=>3000 }}, @config.to_h)
end end
end end

View File

@@ -27,3 +27,5 @@ accessories:
port: 6379 port: 6379
directories: directories:
- data:/data - data:/data
readiness_delay: 0