Compare commits

..

70 Commits

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

Language

Suggest what accessories are

There are also accessories

Default already shown

Better example

Warn about secrets being shown

Now also accessories

Wording

Clarifications

Clarify how to see options

General option for all

Options important here too

Hide subcommands

Implied

Simpler as just version

Be concise

Missing word

Wordsmith

Simpler and uniform words are better

Clarify what exactly we're manipulating

Wordsmithing

Implicit

Simpler language

Hide subcommands

Clarify its container management

Just one per server

Simpler
2023-02-19 17:15:44 +01:00
David Heinemeier Hansson
e5c4cb0344 Retry healthcheck for up to 10 seconds (in case container wasnt ready) 2023-02-19 15:34:36 +01:00
David Heinemeier Hansson
a0d71f3fe4 Protect against missing current version 2023-02-19 09:48:35 +01:00
David Heinemeier Hansson
389ce2f701 Only output if there's a failure 2023-02-19 09:36:04 +01:00
David Heinemeier Hansson
8e918b1906 Output logs when healthcheck fails 2023-02-19 09:33:49 +01:00
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
Paul Gabriel
d484cfcc31 docs(traefik-labels): Improve docs for traefik labels formatting 2023-02-18 00:25:30 +01:00
43 changed files with 728 additions and 269 deletions

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# MRSK
MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be bundled with Docker.
MRSK deploys web apps anywhere from bare metal to cloud VMs using Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be containerized with Docker.
## Installation
@@ -14,7 +14,8 @@ servers:
- 192.168.0.2
registry:
username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %>
password:
- MRSK_REGISTRY_PASSWORD
env:
secret:
- RAILS_MASTER_KEY
@@ -22,8 +23,6 @@ env:
Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app).
Finally, you have to ensure your application can answer `200 OK` to a `GET /up` request. That's how the zero-downtime deploy process knows that your new version is ready to serve traffic.
Now you're ready to deploy to the servers:
```
@@ -32,37 +31,40 @@ mrsk deploy
This will:
1. Connect to the servers over SSH (using root by default, authenticated by your loaded ssh key)
1. Connect to the servers over SSH (using root by default, authenticated by your ssh key)
2. Install Docker on any server that might be missing it (using apt-get)
3. Log into the registry both locally and remotely
4. Build the image using the standard Dockerfile in the root of the application.
5. Push the image to the registry.
6. Pull the image from the registry on the servers.
6. Pull the image from the registry onto the servers.
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.
10. Prune unused images and stopped containers to ensure servers don't fill up.
10. Stop the old container running the previous version of the app.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Vision
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on our own hardware, or even just have a clear migration path to do so, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on our own hardware, or even just have a clear migration path to do so in the future, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated bare metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This structure also gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there's a lot of compelling options available.
This approach gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there'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.
Ultimately, MRSK is meant to compress the complexity of going to production using open source tooling that isn't tied to any commercial offering. Not to zero, mind you. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but as soon as those concepts are familiar, you'll be ready to go with MRSK.
## Why not just run Capistrano, Kubernetes or Docker Swarm?
MRSK basically is Capistrano for Containers, which allow us to use vanilla servers as the hosts. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the deploy servers of MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also allows for quicker deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection.
MRSK basically is Capistrano for Containers, without the need to carefully prepare servers in advance. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the list servers in MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also speeds up deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection.
Kubernetes is a beast. Running it yourself on your own hardware is not for the faint of heart. It's a fine option if you want to run on someone else's platform, either transparently [like Render](https://thenewstack.io/render-cloud-deployment-with-less-engineering/) or explicitly on AWS/GCP, but if you'd like the freedom to move between cloud and your own hardware, or even mix the two, MRSK is much simpler. You can see everything that's going on, it's just basic Docker commands being called.
Docker Swarm is much simpler than Kubernetes, but it's still built on the same declarative model that uses state reconciliation. MRSK is intentionally designed to around imperative commands, like Capistrano.
Ultimately, there are a myriad of ways to deploy web apps, but this is the toolkit we're using at [37signals](https://37signals.com) to bring [HEY](https://www.hey.com) [home from the cloud](https://world.hey.com/dhh/why-we-re-leaving-the-cloud-654b47e0) without losing the advantages of modern containerization tooling.
## Configuration
### Using .env file to load required environment variables
@@ -74,6 +76,23 @@ MRSK_REGISTRY_PASSWORD=pw
DB_PASSWORD=secret123
```
### Using a generated .env file
If you're using a centralized secret store, like 1Password, you can create `.env.erb` as a template which looks up the secrets. Example of a .env.erb file:
```erb
<% if (session_token = `op signin --account my-one-password-account --raw`.strip) != "" %># Generated by mrsk envify
GITHUB_TOKEN=<%= `gh config get -h github.com oauth_token`.strip %>
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Vault/Docker Hub/password" -n --session #{session_token}` %>
RAILS_MASTER_KEY=<%= `op read "op://Vault/My App/RAILS_MASTER_SECRET" -n --session #{session_token}` %>
MYSQL_ROOT_PASSWORD=<%= `op read "op://Vault/My App/MYSQL_ROOT_PASSWORD" -n --session #{session_token}` %>
<% else raise ArgumentError, "Session token missing" end %>
```
This template can safely be checked into git. Then everyone deploying the app can run `mrsk envify` when they setup the app for the first time or passwords change to get the correct `.env` file.
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`.
### Using another registry than Docker Hub
The default registry is Docker Hub, but you can change it using `registry/server`:
@@ -185,10 +204,10 @@ You can specialize the default Traefik rules by setting labels on the containers
```
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.
See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules.
@@ -330,26 +349,24 @@ accessories:
Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `mrsk accessory` for all the commands possible.
### Using a generated .env file
### Using Cron
If you're using a centralized secret store, like 1Password, you can create `.env.erb` as a template which looks up the secrets. Example of a .env.erb file:
You can use a specific container to run your Cron jobs:
```erb
<% if (session_token = `op signin --account my-one-password-account --raw`.strip) != "" %># Generated by mrsk envify
GITHUB_TOKEN=<%= `gh config get -h github.com oauth_token`.strip %>
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Vault/Docker Hub/password" -n --session #{session_token}` %>
RAILS_MASTER_KEY=<%= `op read "op://Vault/My App/RAILS_MASTER_SECRET" -n --session #{session_token}` %>
MYSQL_ROOT_PASSWORD=<%= `op read "op://Vault/My App/MYSQL_ROOT_PASSWORD" -n --session #{session_token}` %>
<% else raise ArgumentError, "Session token missing" end %>
```yaml
servers:
cron:
hosts:
- 192.168.0.1
cmd:
bash -c "cat config/crontab | crontab - && cron -f"
```
This template can safely be checked into git. Then everyone deploying the app can run `mrsk envify` when they setup the app for the first time or passwords change to get the correct `.env` file.
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`.
This assumes the Cron settings are stored in `config/crontab`.
### Using audit broadcasts
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that reads the audit line from STDIN, and then does whatever with it:
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:
@@ -360,16 +377,27 @@ The broadcast command could look something like:
```bash
#!/usr/bin/env bash
read
curl -q -d content="[My app] ${REPLY}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
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] [2023-02-18 11:29:52] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
[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
### Running commands on servers
@@ -437,7 +465,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`:
@@ -487,7 +515,7 @@ If you wish to remove the entire application, including Traefik, containers, ima
## 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

View File

@@ -1,5 +1,5 @@
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)
if name == "all"
MRSK.accessory_names.each { |accessory_name| boot(accessory_name) }
@@ -13,12 +13,12 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}"
audit_broadcast "Booted accessory #{name}" unless options[:skip_broadcast]
end
end
end
desc "upload [NAME]", "Upload accessory files to host"
desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -33,7 +33,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end
end
desc "directories [NAME]", "Create accessory directories on host"
desc "directories [NAME]", "Create accessory directories on host", hide: true
def directories(name)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -44,7 +44,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)
with_accessory(name) do |accessory|
stop(name)
@@ -53,7 +53,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end
end
desc "start [NAME]", "Start existing accessory on host"
desc "start [NAME]", "Start existing accessory container on host"
def start(name)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -63,7 +63,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end
end
desc "stop [NAME]", "Stop accessory on host"
desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -73,7 +73,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end
end
desc "restart [NAME]", "Restart accessory on host"
desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name)
with_accessory(name) do
stop(name)
@@ -81,7 +81,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)
if name == "all"
MRSK.accessory_names.each { |accessory_name| details(accessory_name) }
@@ -92,7 +92,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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 :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(name, cmd)
@@ -123,7 +123,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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 :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)"
@@ -149,21 +149,24 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)
if name == "all"
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
else
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
if options[:confirmed] || ask("This will remove all containers and images for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
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)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -173,7 +176,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)
with_accessory(name) do |accessory|
on(accessory.host) do
@@ -183,7 +186,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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)
with_accessory(name) do |accessory|
on(accessory.host) do

View File

@@ -3,20 +3,26 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
def boot
say "Get most recent version available as an image...", :magenta unless options[: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} using a #{MRSK.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
cli = self
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
execute *MRSK.auditor.record("Booted app version #{version}"), verbosity: :debug
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)
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
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("Rebooted app version #{version}"), verbosity: :debug
execute *MRSK.app.stop(version: version)
execute *MRSK.app.remove_container(version: version)
execute *MRSK.app.run(role: role.name)
else
@@ -28,7 +34,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
on(MRSK.hosts) do
execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug
@@ -36,7 +42,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end
end
desc "stop", "Stop app on servers"
desc "stop", "Stop app container on servers"
def stop
on(MRSK.hosts) do
execute *MRSK.auditor.record("Stopped app"), verbosity: :debug
@@ -44,12 +50,13 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) }
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 :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(cmd)
@@ -91,21 +98,21 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end
end
desc "containers", "List all the app containers currently on servers"
desc "containers", "Show app containers on servers"
def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end
desc "images", "List all the app images currently on servers"
desc "images", "Show app images on servers"
def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end
desc "logs", "Show lines from app on servers"
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"
desc "logs", "Show log lines from app on servers (use --help to show options)"
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 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 :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
# FIXME: Catch when app containers aren't running
@@ -133,11 +140,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove", "Remove app containers and images from servers"
def remove
stop
remove_containers
remove_images
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)
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed app container with version #{version}"), verbosity: :debug
@@ -145,7 +153,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app containers"), verbosity: :debug
@@ -153,7 +161,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
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
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
@@ -161,8 +169,8 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end
end
desc "current_version", "Shows the version currently running"
def current_version
desc "version", "Show app version currently running on servers"
def version
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip }
end
@@ -184,7 +192,12 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
def most_recent_version_available(host: MRSK.primary_host)
version = nil
on(host) { version = capture_with_info(*MRSK.app.most_recent_version_from_available_images).strip }
version.presence
if version == "<none>"
raise "Most recent image available was not tagged with a version (returned <none>)"
else
version.presence
end
end
def current_running_version(host: MRSK.primary_host)

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 :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 :destination, aliases: "-d", desc: "Specify destination to be used for config file (west -> deploy.west.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 (staging -> deploy.staging.yml)"
class_option :skip_broadcast, aliases: "-B", type: :boolean, default: false, desc: "Skip audit broadcasts"
def initialize(*)
super

View File

@@ -1,11 +1,11 @@
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
invoke :push
invoke :pull
push
pull
end
desc "push", "Build locally and push app image to registry"
desc "push", "Build and push app image to registry"
def push
cli = self
@@ -26,15 +26,16 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end
end
desc "pull", "Pull app image from the registry onto servers"
desc "pull", "Pull app image from registry onto servers"
def pull
on(MRSK.hosts) do
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
end
end
desc "create", "Create a local build setup"
desc "create", "Create a build setup"
def create
run_locally do
begin
@@ -51,7 +52,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end
end
desc "remove", "Remove local build setup"
desc "remove", "Remove build setup"
def remove
run_locally do
debug "Using builder: #{MRSK.builder.name}"
@@ -59,7 +60,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
end
end
desc "details", "Show the name of the configured builder"
desc "details", "Show build setup"
def details
run_locally do
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
desc "setup", "Setup all accessories and deploy the app to servers"
desc "setup", "Setup all accessories and deploy app to servers"
def setup
print_runtime do
invoke "mrsk:cli:server:bootstrap"
@@ -8,7 +8,7 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end
end
desc "deploy", "Deploy the app to servers"
desc "deploy", "Deploy app to servers"
def deploy
runtime = print_runtime do
say "Ensure Docker is installed...", :magenta
@@ -23,43 +23,59 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all"
end
audit_broadcast "Deployed in #{runtime.to_i} seconds"
audit_broadcast "Deployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
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
runtime = print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot"
end
audit_broadcast "Redeployed in #{runtime.to_i} seconds"
audit_broadcast "Redeployed app in #{runtime.to_i} seconds" unless options[:skip_broadcast]
end
desc "rollback [VERSION]", "Rollback the app to VERSION"
desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version)
MRSK.version = version
cli = self
if container_name_available?(MRSK.config.service_with_version)
say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
cli.say "Stop current version, then start version #{version}...", :magenta
on(MRSK.hosts) do
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start
cli = self
on(MRSK.hosts) do |host|
old_version = capture_with_info(*MRSK.app.current_running_version).strip.presence
execute *MRSK.app.start
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
audit_broadcast "Rolled back to version #{version}"
end
desc "details", "Display details about Traefik and app containers"
desc "details", "Show details about all containers"
def details
invoke "mrsk:cli:traefik:details"
invoke "mrsk:cli:app:details"
@@ -73,7 +89,7 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end
end
desc "config", "Show combined config"
desc "config", "Show combined config (including secrets!)"
def config
run_locally do
puts MRSK.config.to_h.to_yaml
@@ -123,36 +139,50 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)
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
invoke "mrsk:cli:traefik:remove"
invoke "mrsk:cli:app:remove"
invoke "mrsk:cli:registry:logout"
if options[:confirmed] || ask("This will remove all containers and images. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
invoke "mrsk:cli:accessory:remove", [ "all" ], options
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed)
end
end
desc "version", "Display the MRSK version"
desc "version", "Show MRSK version"
def version
puts Mrsk::VERSION
end
desc "accessory", "Manage the accessories"
desc "accessory", "Manage accessories (db/redis/search)"
subcommand "accessory", Mrsk::Cli::Accessory
desc "app", "Manage the application"
desc "app", "Manage application"
subcommand "app", Mrsk::Cli::App
desc "build", "Build the application image"
desc "build", "Build application image"
subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "prune", "Prune old application images and containers"
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
desc "server", "Bootstrap servers with Docker"
subcommand "server", Mrsk::Cli::Server
desc "traefik", "Manage the Traefik load balancer"
desc "traefik", "Manage Traefik load balancer"
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
end

View File

@@ -1,8 +1,8 @@
class Mrsk::Cli::Prune < Mrsk::Cli::Base
desc "all", "Prune unused images and stopped containers"
def all
invoke :containers
invoke :images
containers
images
end
desc "images", "Prune unused images older than 7 days"
@@ -13,7 +13,7 @@ class Mrsk::Cli::Prune < Mrsk::Cli::Base
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
on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned containers"), verbosity: :debug

View File

@@ -1,15 +1,17 @@
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
run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login }
# FIXME: This rescue needed?
rescue ArgumentError => e
puts e.message
end
desc "logout", "Logout of the registry remotely"
desc "logout", "Log out of registry remotely"
def logout
on(MRSK.hosts) { execute *MRSK.registry.logout }
# FIXME: This rescue needed?
rescue ArgumentError => e
puts e.message
end

View File

@@ -1,5 +1,5 @@
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
on(MRSK.hosts + MRSK.accessory_hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" }
end

View File

@@ -1,5 +1,4 @@
# Name of your application. Used to uniquely configuring Traefik and app containers.
# Your Dockerfile should set LABEL service=the-same-value to ensure image pruning works.
# Name of your application. Used to uniquely configure containers.
service: my-app
# Name of the container image.
@@ -14,4 +13,64 @@ registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
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,12 +6,12 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)"
def reboot
invoke :stop
invoke :remove_container
invoke :boot
stop
remove_container
boot
end
desc "start", "Start existing Traefik on servers"
desc "start", "Start existing Traefik container on servers"
def start
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
@@ -19,7 +19,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
end
end
desc "stop", "Stop Traefik on servers"
desc "stop", "Stop existing Traefik container on servers"
def stop
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
@@ -27,13 +27,13 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
end
end
desc "restart", "Restart Traefik on servers"
desc "restart", "Restart existing Traefik container on servers"
def restart
invoke :stop
invoke :start
stop
start
end
desc "details", "Display details about Traefik containers from servers"
desc "details", "Show details about Traefik container from servers"
def details
on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" }
end
@@ -64,12 +64,12 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove", "Remove Traefik container and image from servers"
def remove
invoke :stop
invoke :remove_container
invoke :remove_image
stop
remove_container
remove_image
end
desc "remove_container", "Remove Traefik container from servers"
desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
@@ -77,7 +77,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
end
end
desc "remove_container", "Remove Traefik image from servers"
desc "remove_container", "Remove Traefik image from servers", hide: true
def remove_image
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug

View File

@@ -49,22 +49,6 @@ class Mrsk::Commander
@app ||= Mrsk::Commands::App.new(config)
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)
Mrsk::Commands::Accessory.new(config, name: name)
end
@@ -73,6 +57,26 @@ class Mrsk::Commander
@auditor ||= Mrsk::Commands::Auditor.new(config)
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)
old_level = self.verbosity

View File

@@ -10,10 +10,10 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def run
docker :run,
"--name", service_name,
"-d",
"--detach",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p", port,
"--publish", port,
*env_args,
*volume_args,
*label_args,
@@ -35,14 +35,14 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil)
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)
end
def follow_logs(grep: nil)
run_over_ssh \
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)
end
@@ -96,11 +96,11 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end
def remove_container
docker :container, :prune, "-f", *service_filter
docker :container, :prune, "--force", *service_filter
end
def remove_image
docker :image, :prune, "-a", "-f", *service_filter
docker :image, :prune, "--all", "--force", *service_filter
end
private

View File

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

View File

@@ -4,16 +4,14 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
# Runs remotely
def record(line)
append \
[ :echo, tagged_line(line) ],
[ :echo, tagged_record_line(line) ],
audit_log_file
end
# Runs locally
def broadcast(line)
if broadcast_cmd = config.audit_broadcast_cmd
pipe \
[ :echo, tagged_line(line) ],
broadcast_cmd
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
@@ -26,19 +24,19 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
"mrsk-#{config.service}-audit.log"
end
def tagged_line(line)
"'#{tags} #{line}'"
def tagged_record_line(line)
"'#{recorded_at_tag} #{performer_tag} #{line}'"
end
def tags
"[#{recorded_at}] [#{performer}]"
def tagged_broadcast_line(line)
"'#{performer_tag} #{line}'"
end
def performer
@performer ||= `whoami`.strip
def performer_tag
"[#{`whoami`.strip}]"
end
def recorded_at
Time.now.to_fs(:db)
def recorded_at_tag
"[#{Time.now.to_fs(:db)}]"
end
end

View File

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

View File

@@ -1,5 +1,5 @@
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
target.class.to_s.remove("Mrsk::Commands::Builder::").underscore

View File

@@ -1,6 +1,10 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils
def clean
docker :image, :rm, "--force", config.absolute_image
end
def pull
docker :pull, config.absolute_image
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,10 +2,19 @@ class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config
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
def logout
docker :logout, registry["server"]
end
private
def lookup_password
if registry["password"].is_a?(Array)
ENV.fetch(registry["password"].first).dup
else
registry["password"]
end
end
end

View File

@@ -1,11 +1,11 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
"-d",
"--detach",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p 80:80",
"-v /var/run/docker.sock:/var/run/docker.sock",
"--publish", "80:80",
"--volume", "/var/run/docker.sock:/var/run/docker.sock",
"traefik",
"--providers.docker",
"--log.level=DEBUG",
@@ -26,23 +26,23 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def logs(since: nil, lines: nil, grep: nil)
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)
end
def follow_logs(host:, grep: nil)
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)
).join(" "), host: host
end
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
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
private

View File

@@ -107,6 +107,7 @@ class Mrsk::Configuration
end
end
def ssh_user
if raw_config.ssh.present?
raw_config.ssh["user"] || "root"
@@ -126,10 +127,18 @@ class Mrsk::Configuration
{ user: ssh_user, proxy: ssh_proxy, auth_methods: [ "publickey" ] }.compact
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?
ensure_required_keys_present && ensure_env_available
@@ -149,7 +158,8 @@ class Mrsk::Configuration
volume_args: volume_args,
ssh_options: ssh_options,
builder: raw_config.builder,
accessories: raw_config.accessories
accessories: raw_config.accessories,
healthcheck: healthcheck
}.compact
end

View File

@@ -58,10 +58,10 @@ class Mrsk::Configuration::Role
def traefik_labels
if running_traefik?
{
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.routers.#{config.service}.rule" => "PathPrefix(`/`)",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.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"
}
else

View File

@@ -1,13 +1,14 @@
module Mrsk::Utils
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)
Array(attributes).flat_map do |k, v|
if v.present?
[ argument, redacted ? redact("#{k}=#{v}") : "#{k}=#{v}" ]
Array(attributes).flat_map do |key, value|
if value.present?
escaped_pair = [ key, value.to_s.dump.gsub(/`/, '\\\\`') ].join("=")
[ argument, redacted ? redact(escaped_pair) : escaped_pair ]
else
[ argument, k ]
[ argument, key ]
end
end
end

View File

@@ -1,3 +1,3 @@
module Mrsk
VERSION = "0.6.4"
VERSION = "0.8.4"
end

View File

@@ -14,7 +14,7 @@ class CliAccessoryTest < CliTestCase
end
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
test "exec" do
@@ -31,6 +31,14 @@ class CliAccessoryTest < CliTestCase
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
def run_command(*command)
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
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
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
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|
assert_match /Rebooting container with same version 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 -a -f name=app-999 -q \| xargs docker container rm/, output # Remove old container
assert_match /Rebooting container with same version 999 already deployed/, output # Can't start what's already 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 --all --filter name=app-999 --quiet | xargs docker container rm/, output # Remove old container
assert_match /docker run/, output # Start new container
end
ensure
@@ -31,7 +45,7 @@ class CliAppTest < CliTestCase
test "stop" do
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
@@ -41,9 +55,17 @@ class CliAppTest < CliTestCase
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
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

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 }
assert_equal Mrsk::VERSION, version
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/, 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

View File

@@ -49,11 +49,11 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do
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(" ")
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(" ")
end
@@ -78,7 +78,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container" do
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(" ")
end
@@ -90,7 +90,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "execute in new container over ssh" 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")
end
end
@@ -106,29 +106,29 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "logs" do
assert_equal \
"docker logs app-mysql -t 2>&1",
"docker logs app-mysql --timestamps 2>&1",
@mysql.logs.join(" ")
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(" ")
end
test "follow logs" do
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
end
test "remove container" do
assert_equal \
"docker container prune -f --filter label=service=app-mysql",
"docker container prune --force --filter label=service=app-mysql",
@mysql.remove_container.join(" ")
end
test "remove image" do
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(" ")
end
end

View File

@@ -14,7 +14,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "run" do
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(" ")
end
@@ -22,7 +22,15 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:volumes] = ["/local/path:/container/path" ]
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(" ")
end
@@ -34,7 +42,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "stop" do
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(" ")
end
@@ -47,38 +55,38 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do
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(" ")
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(" ")
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(" ")
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(" ")
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(" ")
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(" ")
end
test "follow logs" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
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")
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")
end
end
@@ -86,7 +94,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container" do
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(" ")
end
@@ -98,7 +106,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "execute in new container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails c|,
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=\"456\" dhh/app:999 bin/rails c|,
@app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
@@ -137,13 +145,13 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_container_id" do
assert_equal \
"docker ps -q --filter label=service=app",
"docker ps --quiet --filter label=service=app",
@app.current_container_id.join(" ")
end
test "container_id_for" do
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(" ")
end

View File

@@ -16,7 +16,7 @@ class CommandsAuditorTest < ActiveSupport::TestCase
test "broadcast" do
assert_match \
/echo '.* app removed container' \| bin\/audit_broadcast/,
/bin\/audit_broadcast '\[.*\] app removed container'/,
new_command.broadcast("app removed container").join(" ")
end

View File

@@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command
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 --label service=app .",
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
end
@@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "multiarch" => false })
assert_equal "native", builder.name
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app . && docker push dhh/app:123",
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" . && docker push dhh/app:123",
builder.push.join(" ")
end
@@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "local" => { }, "remote" => { } })
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 --label service=app .",
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
end
@@ -33,42 +33,42 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } })
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 --label service=app .",
"docker buildx build --push --platform linux/amd64 --builder mrsk-app-native-remote -t dhh/app:123 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
end
test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=app --build-arg a=1 --build-arg b=2",
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\"",
builder.target.build_options.join(" ")
end
test "build secrets" do
builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=app --secret id=token_a --secret id=token_b",
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\"",
builder.target.build_options.join(" ")
end
test "native push with build args" do
builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app --build-arg a=1 --build-arg b=2 . && docker push dhh/app:123",
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" . && docker push dhh/app:123",
builder.push.join(" ")
end
test "multiarch push with build args" do
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 --label service=app --build-arg a=1 --build-arg b=2 .",
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder mrsk-app-multiarch -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" .",
builder.push.join(" ")
end
test "native push with with build secrets" do
builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=app --secret id=a --secret id=b . && docker push dhh/app:123",
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" . && docker push dhh/app:123",
builder.push.join(" ")
end

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

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

View File

@@ -10,7 +10,7 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "run" do
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(" ")
end
@@ -34,49 +34,49 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "traefik logs" do
assert_equal \
"docker logs traefik -t 2>&1",
"docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ")
end
test "traefik logs since 2h" do
assert_equal \
"docker logs traefik --since 2h -t 2>&1",
"docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ")
end
test "traefik logs last 10 lines" do
assert_equal \
"docker logs traefik -n 10 -t 2>&1",
"docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ")
end
test "traefik logs with grep hello!" do
assert_equal \
"docker logs traefik -t 2>&1 | grep 'hello!'",
"docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ")
end
test "traefik remove container" do
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(" ")
end
test "traefik remove image" do
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(" ")
end
test "traefik follow logs" do
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)
end
test "traefik follow logs with grep hello!" do
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!')
end

View File

@@ -72,20 +72,20 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end
test "label args" do
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-mysql\""], @config.accessory(:mysql).label_args
assert_equal ["--label", "service=\"app-redis\"", "--label", "cache=\"true\""], @config.accessory(:redis).label_args
end
test "env args with secret" do
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)
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end
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
test "volume args" do

View File

@@ -38,11 +38,11 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end
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
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
test "custom labels" do
@@ -57,8 +57,8 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end
test "overwriting default traefik label" do
@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"]
@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"]
end
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" ] }
})
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
test "env overwritten by role" do
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
test "env secret overwritten by role" do
@@ -95,9 +95,9 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
}
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
ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil
@@ -116,7 +116,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
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
ENV["DB_PASSWORD"] = nil
end
@@ -133,7 +133,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
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
ENV["REDIS_PASSWORD"] = nil
end

View File

@@ -89,7 +89,7 @@ class ConfigurationTest < ActiveSupport::TestCase
end
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
test "env args with clear and secrets" do
@@ -98,7 +98,7 @@ class ConfigurationTest < ActiveSupport::TestCase
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)
ensure
ENV["PASSWORD"] = nil
@@ -109,7 +109,7 @@ class ConfigurationTest < ActiveSupport::TestCase
env: { "clear" => { "PORT" => "3000" } }
}) })
assert_equal [ "-e", "PORT=3000" ], config.env_args
assert_equal [ "-e", "PORT=\"3000\"" ], config.env_args
end
test "env args with only secrets" do
@@ -118,7 +118,7 @@ class ConfigurationTest < ActiveSupport::TestCase
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)
ensure
ENV["PASSWORD"] = nil
@@ -181,6 +181,6 @@ class ConfigurationTest < ActiveSupport::TestCase
end
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

View File

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