Compare commits

..

62 Commits

Author SHA1 Message Date
David Heinemeier Hansson
e37e5f7d09 Bump version for 0.7.2 2023-02-18 18:23:28 +01:00
David Heinemeier Hansson
7f1191bf59 Change broadcast cmd to just take an argument instead of STDIN
Simpler
2023-02-18 18:22:46 +01:00
David Heinemeier Hansson
0c03216fdf Bump version for 0.7.1 2023-02-18 16:33:28 +01:00
David Heinemeier Hansson
1973f55c58 Don't include recorded_at with broadcast line
Receiving end will already add that
2023-02-18 16:33:12 +01:00
David Heinemeier Hansson
0a51cd0899 Update for healthcheck config 2023-02-18 16:28:31 +01:00
David Heinemeier Hansson
4b0a8728f1 Bump version for 0.7.0 2023-02-18 16:27:08 +01:00
David Heinemeier Hansson
3075f8daf1 Include healthcheck in config 2023-02-18 16:26:23 +01:00
David Heinemeier Hansson
9985834bd6 Use number 2023-02-18 16:26:17 +01:00
David Heinemeier Hansson
94b4461c76 Merge pull request #52 from mrsked/health-check-with-deploy
Add healthcheck before deploy
2023-02-18 16:24:41 +01:00
David Heinemeier Hansson
7afa9e0815 Mention healthcheck as part of steps instead 2023-02-18 16:23:46 +01:00
David Heinemeier Hansson
933ece35ab Add healthcheck before deploy 2023-02-18 16:22:08 +01:00
David Heinemeier Hansson
2f80b300f0 Test rolling back to a good version too 2023-02-18 14:55:11 +01:00
David Heinemeier Hansson
2e06bf59a4 Protect against rolling back to a bad version 2023-02-18 14:33:47 +01:00
David Heinemeier Hansson
854795c2b6 Wording 2023-02-18 12:10:42 +01:00
David Heinemeier Hansson
4fe7fb705a Use same sentence style as broadcasts for audit log lines 2023-02-18 12:00:15 +01:00
David Heinemeier Hansson
270e0d0e2c Merge pull request #50 from pagbrl/labels-traefik-docs
docs(traefik-labels): Improve docs for traefik labels formatting
2023-02-18 11:42:43 +01:00
David Heinemeier Hansson
6ddc9cf017 Merge pull request #51 from mrsked/audit-broadcasts
Add audit broadcasts
2023-02-18 11:41:19 +01:00
David Heinemeier Hansson
2dcd76b2de Merge branch 'main' into audit-broadcasts
* main:
  Remove unnecessary audit recordings
2023-02-18 11:38:34 +01:00
David Heinemeier Hansson
a6eabd0b67 Remove unnecessary audit recordings 2023-02-18 11:36:52 +01:00
David Heinemeier Hansson
fb9357b5ba Add audit broadcasts 2023-02-18 11:36:30 +01:00
Paul Gabriel
d484cfcc31 docs(traefik-labels): Improve docs for traefik labels formatting 2023-02-18 00:25:30 +01:00
David Heinemeier Hansson
5c93642f2a Prepare for custom pruning 2023-02-15 20:34:08 +01:00
David Heinemeier Hansson
8ff206ba7e Highlight 2023-02-15 18:08:46 +01:00
David Heinemeier Hansson
e36a5e111c Make a note about the /up requirement 2023-02-15 18:08:26 +01:00
David Heinemeier Hansson
72522001e5 Merge pull request #46 from fschueller/fix-prune-desc
Adjust CLI description for prune command to mention 7 days
2023-02-15 14:09:06 +01:00
David Heinemeier Hansson
50c4bb83cb Bump version for 0.6.4 2023-02-15 13:48:10 +01:00
David Heinemeier Hansson
b2875ad056 More readable tests 2023-02-15 13:47:16 +01:00
David Heinemeier Hansson
8ec94f105c Tag images with service label so we can prune exclusively 2023-02-15 13:41:03 +01:00
David Heinemeier Hansson
90f4212a68 Stray copypasta 2023-02-15 13:39:53 +01:00
David Heinemeier Hansson
648894f9a9 No need for quoting 2023-02-15 13:32:59 +01:00
David Heinemeier Hansson
dc68639dfa Prune all unused images matching time filter 2023-02-15 13:32:50 +01:00
David Heinemeier Hansson
244cf8b3b7 Add prune command test 2023-02-15 13:30:31 +01:00
David Heinemeier Hansson
f25f506d77 Don't use abbreviations when we don't have to 2023-02-15 13:26:57 +01:00
David Heinemeier Hansson
c29a177a7a DRY the use of build options into one call 2023-02-15 13:23:14 +01:00
Farah Schüller
03328a998c Adjust CLI description for prune command to mention 7 days 2023-02-14 17:05:36 +01:00
David Heinemeier Hansson
ec5fad5bea Describe the vision 2023-02-11 14:30:23 +01:00
David Heinemeier Hansson
c671acf68f Bump version for 0.6.3 2023-02-11 13:10:47 +01:00
David Heinemeier Hansson
4f2cb5e184 Shorter 2023-02-11 13:00:22 +01:00
David Heinemeier Hansson
63a065237a Ensure .env file is only accessible to user 2023-02-11 12:56:57 +01:00
David Heinemeier Hansson
0f4e1888d9 Just delete the full cache directory, it isnt needed 2023-02-10 14:35:11 +01:00
David Heinemeier Hansson
d4d3308c34 Need to use args 2023-02-09 21:50:57 +01:00
David Heinemeier Hansson
b9c6d2966b Bump version for 0.6.2 2023-02-09 19:57:39 +01:00
David Heinemeier Hansson
f371cda8d8 Stick with json logger for filebeat compatibility but cap at 10mb 2023-02-09 19:56:17 +01:00
David Heinemeier Hansson
9eaf0f3b8f Lower default prune target for images to 7 days. Its just a local convenience cache. Dont risk filling up the disk on very active development. 2023-02-09 18:07:52 +01:00
David Heinemeier Hansson
a80289d046 Use local log driver for everything
Auto rotation, max is 100mb
2023-02-09 17:02:15 +01:00
David Heinemeier Hansson
aae45afb1b Easier to read tests 2023-02-09 17:01:35 +01:00
David Heinemeier Hansson
f4157c95c4 Easier to read tests 2023-02-09 16:55:09 +01:00
David Heinemeier Hansson
bb5176673b Deal with lazy-setting of configuration 2023-02-08 14:24:16 +01:00
David Heinemeier Hansson
e9cb5b64b3 Remove Fly as an example of k8s 2023-02-08 14:14:52 +01:00
David Heinemeier Hansson
0433619518 Tag new builds with latest 2023-02-08 14:08:36 +01:00
David Heinemeier Hansson
110bf44a3b Recommend single layer 2023-02-08 10:27:27 +01:00
David Heinemeier Hansson
fbdf39a733 Code highlighting 2023-02-08 08:37:33 +01:00
David Heinemeier Hansson
f99ff47f75 Make sure folks dont leak GITHUB_TOKENs into the image when using git dependencies 2023-02-08 08:35:30 +01:00
David Heinemeier Hansson
bb18189b01 Bump version for 0.6.1 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
18bdb33de2 Fix issue with removing containers triggering twice, then ensure app stop runs closer to app run on each host 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
1ec016ecad Add a brief note about Docker Swarm
A deeper comparison would be nice at some point.
2023-02-07 13:58:26 +01:00
David Heinemeier Hansson
bd61e04088 Merge pull request #38 from tbuehlmann/native-builder-image-tag-position
Move image tag to proper position
2023-02-06 09:22:57 +01:00
David Heinemeier Hansson
0da2a6408b Merge pull request #39 from adammiribyan/outside-git
Commit hash as version but not in git
2023-02-06 09:22:25 +01:00
David Heinemeier Hansson
9697a9a6e0 Merge pull request #40 from adammiribyan/gemspec
Match README
2023-02-06 09:21:57 +01:00
Adam Miribyan
32d52b024c Match README
Update gemspec description to match what's in README
2023-02-05 23:09:08 +01:00
Adam
2fe01f13df Commit hash version but not in git
Fixes #11
2023-02-05 20:31:14 +01:00
Tobias Bühlmann
554a3558ab Move image tag to proper position 2023-02-05 18:39:52 +01:00
37 changed files with 551 additions and 141 deletions

View File

@@ -1,7 +1,7 @@
PATH
remote: .
specs:
mrsk (0.6.0)
mrsk (0.7.2)
activesupport (>= 7.0)
dotenv (~> 2.8)
sshkit (~> 1.21)

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.
MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be bundled with Docker.
## Installation
@@ -20,7 +20,7 @@ env:
- RAILS_MASTER_KEY
```
Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app).
Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app).
Now you're ready to deploy to the servers:
@@ -37,17 +37,30 @@ This will:
5. Push the image to the registry.
6. Pull the image from the registry on the servers.
7. Ensure Traefik is running and accepting traffic on port 80.
8. Stop any containers running a previous versions of the app.
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.
8. Ensure your app responds with `200 OK` to `GET /up`.
9. Stop any containers running a previous versions of the app.
10. Start a new container with the version of the app that matches the current git version hash.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Why not just run Capistrano or Kubernetes?
## Vision
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on our own hardware, or even just have a clear migration path to do so, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This structure also gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there's a lot of compelling options available.
Ultimately, MRSK is meant to compress the complexity of going to production using open source tooling that isn't tied to any commercial offering. Not to zero, though. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but from an early stage when those concepts are familiar.
## Why not just run Capistrano, Kubernetes or Docker Swarm?
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.
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, like Render or Fly, 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.
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.
## Configuration
@@ -171,10 +184,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.
@@ -247,14 +260,15 @@ builder:
This build secret can then be referenced in the Dockerfile:
```
```dockerfile
# Copy Gemfiles
COPY Gemfile Gemfile.lock ./
# Install dependencies, including private repositories via access token
# Install dependencies, including private repositories via access token (then remove bundle cache with exposed GITHUB_TOKEN)
RUN --mount=type=secret,id=GITHUB_TOKEN \
BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \
bundle install
bundle install && \
rm -rf /usr/local/bundle/cache
```
### Using command arguments for Traefik
@@ -263,12 +277,13 @@ You can customize the traefik command line:
```yaml
traefik:
accesslog: true
accesslog.format: json
metrics.prometheus: true
metrics.prometheus.buckets: 0.1,0.3,1.2,5.0
args:
accesslog: true
accesslog.format: json
```
This will start the traefik container with `--accesslog=true accesslog.format=json`.
### Configuring build args for new images
Build arguments that aren't secret can also be configured:
@@ -331,6 +346,40 @@ This template can safely be checked into git. Then everyone deploying the app ca
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`.
### Using audit broadcasts
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument:
```yaml
audit_broadcast_cmd:
bin/audit_broadcast
```
The broadcast command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like follows to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
### Using custom healthcheck path or port
MRSK defaults to checking the health of your application again `/up` on port 3000. You can tailor both with the `healthcheck` setting:
```yaml
healthcheck:
path: /healthz
port: 4000
```
This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
## Commands
### Running commands on servers

View File

@@ -9,9 +9,11 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
upload(name)
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} boot"), verbosity: :debug
execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}"
end
end
end
@@ -20,8 +22,6 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def upload(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} upload files"), verbosity: :debug
accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local)
@@ -37,8 +37,6 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def directories(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} create directories"), verbosity: :debug
accessory.directories.keys.each do |host_path|
execute *accessory.make_directory(host_path)
end
@@ -59,7 +57,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def start(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} start"), verbosity: :debug
execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start
end
end
@@ -69,7 +67,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def stop(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} stop"), verbosity: :debug
execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
end
@@ -111,14 +109,14 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
when options[:reuse]
say "Launching command from existing container...", :magenta
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug
execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd))
end
else
say "Launching command from new container...", :magenta
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug
execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd))
end
end
@@ -169,7 +167,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def remove_container(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove container"), verbosity: :debug
execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container
end
end
@@ -179,7 +177,7 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def remove_image(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove image"), verbosity: :debug
execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug
execute *accessory.remove_image
end
end
@@ -189,7 +187,6 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
def remove_service_directory(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove service directory"), verbosity: :debug
execute *accessory.remove_service_directory
end
end

View File

@@ -1,26 +1,23 @@
class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)"
def boot
cli = self
say "Ensure no other version of the app is running...", :magenta
stop
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
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
execute *MRSK.auditor.record("app boot version #{version}"), verbosity: :debug
execute *MRSK.auditor.record("Booted app version #{version}"), verbosity: :debug
begin
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Rebooting container with same version already deployed on #{host}"
execute *MRSK.auditor.record("Rebooted app version #{version}"), verbosity: :debug
cli.remove_container version
execute *MRSK.app.remove_container(version: version)
execute *MRSK.app.run(role: role.name)
else
raise
@@ -34,7 +31,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)"
def start
on(MRSK.hosts) do
execute *MRSK.auditor.record("app start version #{MRSK.version}"), verbosity: :debug
execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug
execute *MRSK.app.start, raise_on_non_zero_exit: false
end
end
@@ -42,7 +39,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "stop", "Stop app on servers"
def stop
on(MRSK.hosts) do
execute *MRSK.auditor.record("app stop"), verbosity: :debug
execute *MRSK.auditor.record("Stopped app"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false
end
end
@@ -77,7 +74,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
say "Launching command with version #{version} from existing container...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug
execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_existing_container(cmd))
end
end
@@ -87,7 +84,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
using_version(options[:version] || most_recent_version_available) do |version|
say "Launching command with version #{version} from new container...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug
execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd))
end
end
@@ -143,7 +140,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove_container [VERSION]", "Remove app container with given version from servers"
def remove_container(version)
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove container #{version}"), verbosity: :debug
execute *MRSK.auditor.record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
end
end
@@ -151,7 +148,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove_containers", "Remove all app containers from servers"
def remove_containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove containers"), verbosity: :debug
execute *MRSK.auditor.record("Removed all app containers"), verbosity: :debug
execute *MRSK.app.remove_containers
end
end
@@ -159,7 +156,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
desc "remove_images", "Remove all app images from servers"
def remove_images
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove images"), verbosity: :debug
execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images
end
end

View File

@@ -59,9 +59,14 @@ module Mrsk::Cli
def print_runtime
started_at = Time.now
yield
return Time.now - started_at
ensure
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
end
end

View File

@@ -29,7 +29,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "pull", "Pull app image from the registry onto servers"
def pull
on(MRSK.hosts) do
execute *MRSK.auditor.record("build pull image #{MRSK.version}"), verbosity: :debug
execute *MRSK.auditor.record("Pulled image with version #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.pull
end
end

View File

@@ -0,0 +1,29 @@
class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base
desc "perform", "Health check the current version of the app"
def perform
on(MRSK.primary_host) do
begin
execute *MRSK.healthcheck.run
target = "Health check against #{MRSK.config.healthcheck["path"]}"
if capture_with_info(*MRSK.healthcheck.curl) == "200"
info "#{target} succeeded with 200 OK!"
else
# Catches 1xx, 2xx, 3xx
raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!"
end
rescue SSHKit::Command::Failed => e
if e.message =~ /curl/
# Catches 4xx, 5xx
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

@@ -10,7 +10,7 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "deploy", "Deploy the app to servers"
def deploy
print_runtime do
runtime = print_runtime do
say "Ensure Docker is installed...", :magenta
invoke "mrsk:cli:server:bootstrap"
@@ -23,33 +23,48 @@ 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 app in #{runtime.to_i} seconds"
end
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def redeploy
print_runtime do
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 app in #{runtime.to_i} seconds"
end
desc "rollback [VERSION]", "Rollback the app to VERSION"
def rollback(version)
MRSK.version = version
cli = self
if container_name_available?(MRSK.config.service_with_version)
say "Stop current version, then start version #{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
on(MRSK.hosts) do |host|
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start
end
audit_broadcast "Rolled back app to version #{version}"
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end
end
@@ -107,10 +122,14 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
def envify
if destination = options[:destination]
File.write(".env.#{destination}", ERB.new(IO.read(Pathname.new(File.expand_path(".env.#{destination}.erb")))).result)
env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else
File.write(".env", ERB.new(IO.read(Pathname.new(File.expand_path(".env.erb")))).result)
env_template_path = ".env.erb"
env_path = ".env"
end
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"
@@ -134,6 +153,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "build", "Build the application image"
subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck the application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune
@@ -145,4 +167,11 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "traefik", "Manage the 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

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

View File

@@ -14,7 +14,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "start", "Start existing Traefik on servers"
def start
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik start"), verbosity: :debug
execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false
end
end
@@ -22,7 +22,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "stop", "Stop Traefik on servers"
def stop
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik stop"), verbosity: :debug
execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
end
end
@@ -72,7 +72,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove_container", "Remove Traefik container from servers"
def remove_container
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove container"), verbosity: :debug
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end
@@ -80,7 +80,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove_container", "Remove Traefik image from servers"
def remove_image
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove image"), verbosity: :debug
execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end

View File

@@ -73,12 +73,20 @@ class Mrsk::Commander
@auditor ||= Mrsk::Commands::Auditor.new(config)
end
def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def with_verbosity(level)
old_level = SSHKit.config.output_verbosity
old_level = self.verbosity
self.verbosity = level
SSHKit.config.output_verbosity = level
yield
ensure
self.verbosity = old_level
SSHKit.config.output_verbosity = old_level
end
@@ -91,7 +99,15 @@ class Mrsk::Commander
private
def cascading_version
version.presence || ENV["VERSION"] || `git rev-parse HEAD`.strip
version.presence || ENV["VERSION"] || current_commit_hash
end
def current_commit_hash
if system("git rev-parse")
`git rev-parse HEAD`.strip
else
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end
end
# Lazy setup of SSHKit

View File

@@ -12,6 +12,7 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
"--name", service_name,
"-d",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p", port,
*env_args,
*volume_args,

View File

@@ -5,6 +5,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :run,
"-d",
"--restart unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--name", service_with_version,
*role.env_args,
*config.volume_args,
@@ -74,10 +75,6 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :ps, "-q", *service_filter
end
def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q"
end
def current_running_version
# FIXME: Find more graceful way to extract the version from "app-version" than using sed and tail!
pipe \
@@ -92,11 +89,21 @@ 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
end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
end
def remove_container(version:)
pipe \
container_id_for(container_name: service_with_version(version)),

View File

@@ -1,12 +1,20 @@
require "active_support/core_ext/time/conversions"
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
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
def reveal
[ :tail, "-n", 50, audit_log_file ]
end
@@ -16,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
"[#{timestamp}] [#{performer}]"
def tagged_broadcast_line(line)
"'#{performer_tag} #{line}'"
end
def performer
`whoami`.strip
def performer_tag
"[#{`whoami`.strip}]"
end
def timestamp
Time.now.to_fs(:db)
def recorded_at_tag
"[#{Time.now.to_fs(:db)}]"
end
end

View File

@@ -2,6 +2,8 @@ module Mrsk::Commands
class Base
delegate :redact, to: Mrsk::Utils
MAX_LOG_SIZE = "10m"
attr_accessor :config
def initialize(config)
@@ -15,6 +17,10 @@ module Mrsk::Commands
end
end
def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q"
end
private
def combine(*commands, by: "&&")
commands

View File

@@ -5,15 +5,27 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
docker :pull, config.absolute_image
end
def build_args
argumentize "--build-arg", args, redacted: true
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
def build_options
[ *build_tags, *build_labels, *build_args, *build_secrets ]
end
private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
end
def build_labels
argumentize "--label", { service: config.service }
end
def build_args
argumentize "--build-arg", args, redacted: true
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end
def args
(config.builder && config.builder["args"]) || {}
end

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
EXPOSED_PORT = 3999
def run
web = config.role(:web)
docker :run,
"-d",
"--name", container_name_with_version,
"-p", "#{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}'", health_url ]
end
def stop
pipe \
container_id_for(container_name: container_name),
xargs(docker(:stop))
end
def remove
pipe \
container_id_for(container_name: container_name),
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 health_url
"http://localhost:#{EXPOSED_PORT}#{config.healthcheck["path"]}"
end
end

View File

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

View File

@@ -2,7 +2,8 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
"-d",
"--restart unless-stopped",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p 80:80",
"-v /var/run/docker.sock:/var/run/docker.sock",
"traefik",

View File

@@ -82,6 +82,10 @@ class Mrsk::Configuration
"#{repository}:#{version}"
end
def latest_image
"#{repository}:latest"
end
def service_with_version
"#{service}-#{version}"
end
@@ -103,6 +107,7 @@ class Mrsk::Configuration
end
end
def ssh_user
if raw_config.ssh.present?
raw_config.ssh["user"] || "root"
@@ -123,6 +128,15 @@ class Mrsk::Configuration
end
def audit_broadcast_cmd
raw_config.audit_broadcast_cmd
end
def healthcheck
{ "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {})
end
def valid?
ensure_required_keys_present && ensure_env_available
end
@@ -141,7 +155,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

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

View File

@@ -1,3 +1,3 @@
module Mrsk
VERSION = "0.6.0"
VERSION = "0.7.2"
end

View File

@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
spec.authors = [ "David Heinemeier Hansson" ]
spec.email = "dhh@hey.com"
spec.homepage = "https://github.com/rails/mrsk"
spec.summary = "Deploy Rails apps in containers to servers running Docker with zero downtime."
spec.summary = "Deploy web apps in containers to servers running Docker with zero downtime."
spec.license = "MIT"
spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"]

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 -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 -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")
end
test "exec" do

View File

@@ -5,4 +5,29 @@ 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 -a --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 /Stop current version, then 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
private
def run_command(*command)
stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

View File

@@ -9,6 +9,16 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal Mrsk::Configuration, @mrsk.config.class
end
test "commit hash as version" do
assert_equal `git rev-parse HEAD`.strip, @mrsk.config.version
end
test "commit hash as version but not in git" do
@mrsk.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @mrsk.config }
assert_match /no git repository found/, error.message
end
test "overwriting hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts

View File

@@ -49,35 +49,43 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "run" do
assert_equal \
[:docker, :run, "--name", "app-mysql", "-d", "--restart", "unless-stopped", "-p", "3306:3306", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "--label", "service=app-mysql", "mysql:8.0"], @mysql.run
"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",
@mysql.run.join(" ")
assert_equal \
[:docker, :run, "--name", "app-redis", "-d", "--restart", "unless-stopped", "-p", "6379:6379", "-e", "SOMETHING=else", "--volume", "/var/lib/redis:/data", "--label", "service=app-redis", "--label", "cache=true", "redis:latest"], @redis.run
"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",
@redis.run.join(" ")
end
test "start" do
assert_equal [:docker, :container, :start, "app-mysql"], @mysql.start
assert_equal \
"docker container start app-mysql",
@mysql.start.join(" ")
end
test "stop" do
assert_equal [:docker, :container, :stop, "app-mysql"], @mysql.stop
assert_equal \
"docker container stop app-mysql",
@mysql.stop.join(" ")
end
test "info" do
assert_equal [:docker, :ps, "--filter", "label=service=app-mysql"], @mysql.info
assert_equal \
"docker ps --filter label=service=app-mysql",
@mysql.info.join(" ")
end
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" ],
@mysql.execute_in_new_container("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
test "execute in existing container" do
assert_equal \
[ :docker, :exec, "app-mysql", "mysql", "-u", "root" ],
@mysql.execute_in_existing_container("mysql", "-u", "root")
"docker exec app-mysql mysql -u root",
@mysql.execute_in_existing_container("mysql", "-u", "root").join(" ")
end
test "execute in new container over ssh" do
@@ -97,19 +105,30 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
test "logs" do
assert_equal [:docker, :logs, "app-mysql", "-t", "2>&1"], @mysql.logs
assert_equal [:docker, :logs, "app-mysql", " --since 5m", " -n 100", "-t", "2>&1", "|", "grep 'thing'"], @mysql.logs(since: "5m", lines: 100, grep: "thing")
assert_equal \
"docker logs app-mysql -t 2>&1",
@mysql.logs.join(" ")
assert_equal \
"docker logs app-mysql --since 5m -n 100 -t 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'", @mysql.follow_logs
assert_equal \
"ssh -t root@1.1.1.5 'docker logs app-mysql -t -n 10 -f 2>&1'",
@mysql.follow_logs
end
test "remove container" do
assert_equal [:docker, :container, :prune, "-f", "--filter", "label=service=app-mysql"], @mysql.remove_container
assert_equal \
"docker container prune -f --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"], @mysql.remove_image
assert_equal \
"docker image prune -a -f --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 --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 -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",
@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 --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 -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",
@app.run.join(" ")
end
test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
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=/healthz --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",
@app.run.join(" ")
end

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
require "test_helper"
class CommandsHealthcheckTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "run" do
assert_equal \
"docker run -d --name healthcheck-app-123 -p 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 -d --name healthcheck-app-123 -p 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}' 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}' http://localhost:3999/healthz",
new_command.curl.join(" ")
end
test "stop" do
assert_equal \
"docker container ls -a -f name=healthcheck-app -q | xargs docker stop",
new_command.stop.join(" ")
end
test "remove" do
assert_equal \
"docker container ls -a -f name=healthcheck-app -q | xargs docker container rm",
new_command.remove.join(" ")
end
private
def new_command
Mrsk::Commands::Healthcheck.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

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

View File

@@ -10,63 +10,74 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "run" do
assert_equal \
[:docker, :run, "--name traefik", "-d", "--restart unless-stopped", "-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"],
new_command.run
"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",
new_command.run.join(" ")
end
test "traefik start" do
assert_equal \
[:docker, :container, :start, 'traefik'], new_command.start
"docker container start traefik",
new_command.start.join(" ")
end
test "traefik stop" do
assert_equal \
[:docker, :container, :stop, 'traefik'], new_command.stop
"docker container stop traefik",
new_command.stop.join(" ")
end
test "traefik info" do
assert_equal \
[:docker, :ps, '--filter', 'name=traefik'], new_command.info
"docker ps --filter name=traefik",
new_command.info.join(" ")
end
test "traefik logs" do
assert_equal \
[:docker, :logs, 'traefik', '-t', '2>&1'], new_command.logs
"docker logs traefik -t 2>&1",
new_command.logs.join(" ")
end
test "traefik logs since 2h" do
assert_equal \
[:docker, :logs, 'traefik', ' --since 2h', '-t', '2>&1'], new_command.logs(since: '2h')
"docker logs traefik --since 2h -t 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'], new_command.logs(lines: 10)
"docker logs traefik -n 10 -t 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!'"], new_command.logs(grep: 'hello!')
"docker logs traefik -t 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"], new_command.remove_container
"docker container prune -f --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"], new_command.remove_image
"docker image prune -a -f --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'", new_command.follow_logs(host: @config[:servers].first)
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 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!\"'", new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1 | grep \"hello!\"'",
new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end
private

View File

@@ -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