Compare commits

..

141 Commits

Author SHA1 Message Date
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
David Heinemeier Hansson
9aa57dd0c7 Bump version for 0.6.0 2023-02-05 17:53:43 +01:00
David Heinemeier Hansson
cb9f57356e Load destination ENV file also 2023-02-05 17:52:57 +01:00
David Heinemeier Hansson
02a5726072 Allow destination specific envifying 2023-02-05 16:35:37 +01:00
David Heinemeier Hansson
e865e823d5 Add envify for managing .env file 2023-02-05 16:30:56 +01:00
David Heinemeier Hansson
10cad5c459 Create binstub without bundler, document it all agnostically
You can use MRSK with something other than Rails.
2023-02-05 16:23:34 +01:00
David Heinemeier Hansson
ebcb297582 Merge pull request #24 from chrisdebruin/allow-bastion-server
Allow use of bastion host
2023-02-04 15:44:30 +01:00
David Heinemeier Hansson
0a293ae4d6 Fix and expand testing 2023-02-04 15:43:45 +01:00
Chris de Bruin
bdff11e1fc Allow use of bastion host 2023-02-04 15:38:05 +01:00
David Heinemeier Hansson
9cfb6fb0a9 Merge issue 2023-02-04 15:34:48 +01:00
David Heinemeier Hansson
9ec6f9d74f Merge branch 'main' into allow-bastion-server 2023-02-04 15:33:25 +01:00
David Heinemeier Hansson
45207f0c4f Explain the dance 2023-02-04 15:27:41 +01:00
David Heinemeier Hansson
cf9a402ad8 Stop treating RAILS_MASTER_KEY as special 2023-02-04 15:26:59 +01:00
David Heinemeier Hansson
64a5a790a7 Ensure secret can be used alone 2023-02-04 15:26:43 +01:00
David Heinemeier Hansson
78d4e1e1e9 Easier to read 2023-02-04 15:12:06 +01:00
David Heinemeier Hansson
74c7a6d5de Expand app command testing 2023-02-04 10:31:04 +01:00
David Heinemeier Hansson
340929e7e7 Use a version 2023-02-04 10:20:51 +01:00
David Heinemeier Hansson
6f1a3f5524 Don't need this, just use containers 2023-02-04 10:16:24 +01:00
David Heinemeier Hansson
7077da5a64 Spacing 2023-02-04 10:15:43 +01:00
David Heinemeier Hansson
77c63dcd04 Style 2023-02-04 10:14:35 +01:00
David Heinemeier Hansson
e7ac73be5a Join in run_over_ssh instead of all over 2023-02-04 10:14:31 +01:00
David Heinemeier Hansson
dfca9d8c48 Merge branch 'main' into allow-bastion-server 2023-02-04 10:06:15 +01:00
David Heinemeier Hansson
6032d5651a Merge pull request #35 from rails/zeitwerk
Load with Zeitwerk
2023-02-04 10:05:43 +01:00
Xavier Noria
539752e9bd Load with Zeitwerk 2023-02-03 22:45:12 +01:00
David Heinemeier Hansson
94b28a1b29 Extract method 2023-02-03 20:53:33 +01:00
David Heinemeier Hansson
5911914e95 Bump version for 0.5.1 2023-02-03 20:48:21 +01:00
David Heinemeier Hansson
3daecf696a Extract proper auditor and audit everything 2023-02-03 20:45:32 +01:00
David Heinemeier Hansson
497c57e3e5 Style 2023-02-03 20:44:43 +01:00
David Heinemeier Hansson
8a42fd2f30 Fix signature 2023-02-03 20:43:22 +01:00
David Heinemeier Hansson
2182cfb5c7 Bump version for 0.5.0 2023-02-03 17:49:47 +01:00
David Heinemeier Hansson
5c9a602d76 Fixed host 2023-02-03 17:46:41 +01:00
David Heinemeier Hansson
b964e04f93 Bring accessory execution in line with app 2023-02-03 17:24:36 +01:00
David Heinemeier Hansson
1fb2c71f65 Follow same dot style 2023-02-03 17:22:55 +01:00
David Heinemeier Hansson
58417f610f Dupe comment 2023-02-03 17:20:14 +01:00
David Heinemeier Hansson
5856a77a53 Bring accessory execution in line with app 2023-02-03 17:19:20 +01:00
David Heinemeier Hansson
5ed3ea9d26 Grouping by spacing 2023-02-03 17:18:58 +01:00
David Heinemeier Hansson
59199cc69a Fix bug 2023-02-03 17:18:47 +01:00
David Heinemeier Hansson
c453b947e0 Add exec tests 2023-02-03 17:18:42 +01:00
David Heinemeier Hansson
87e54d41e4 Need two stubs! 2023-02-03 17:03:26 +01:00
David Heinemeier Hansson
64b91daab1 Drop concerns
Not enough reuse possible
2023-02-03 16:55:34 +01:00
David Heinemeier Hansson
13e22f8a34 Repository really is app specific, since it relies on versions 2023-02-03 16:45:52 +01:00
David Heinemeier Hansson
8848335fbc Extract executions into separate concern 2023-02-03 16:39:26 +01:00
David Heinemeier Hansson
a3fe8856c9 Fix test 2023-02-03 16:27:16 +01:00
David Heinemeier Hansson
d263b0ffa5 Extract xargs helper 2023-02-03 16:27:10 +01:00
David Heinemeier Hansson
3c1053fedd Clarify exec modes and drop tailored versions 2023-02-03 16:07:25 +01:00
David Heinemeier Hansson
a3d998508b Proper versioning for console and bash 2023-02-03 15:16:40 +01:00
David Heinemeier Hansson
3d71ecdf80 Only say if you're going to do it 2023-02-03 15:16:30 +01:00
David Heinemeier Hansson
37e216f2b7 Add some more tests 2023-02-03 15:08:44 +01:00
David Heinemeier Hansson
17e75ec2c9 No more reboot 2023-02-03 15:06:43 +01:00
David Heinemeier Hansson
7621784235 Bring back regular version with narration 2023-02-03 15:05:34 +01:00
David Heinemeier Hansson
687b8c9def Rely on shared --version 2023-02-03 14:41:39 +01:00
David Heinemeier Hansson
13d4eb4017 Narrate multi-stage actions 2023-02-03 14:41:30 +01:00
David Heinemeier Hansson
78f0be9c76 Only multi-stage actions should talk 2023-02-03 14:33:49 +01:00
David Heinemeier Hansson
839a0df40e Boot now does its own stopping 2023-02-03 14:31:56 +01:00
David Heinemeier Hansson
74c493def4 Don't actually need reboot now that boot can do that 2023-02-03 14:31:11 +01:00
Chris de Bruin
7d95472543 Added -J for ssh proxy 2023-02-03 14:31:09 +01:00
David Heinemeier Hansson
71681cb8be Use single string-based proxy declaration 2023-02-03 14:30:20 +01:00
Chris de Bruin
1fef6ba505 Allow use of bastion host 2023-02-03 14:30:20 +01:00
David Heinemeier Hansson
22bbedf298 Show current running version 2023-02-03 14:08:00 +01:00
David Heinemeier Hansson
15a213eec6 Escape pipe and test for xargs 2023-02-03 14:07:52 +01:00
David Heinemeier Hansson
67f9ffe961 xargs when piping 2023-02-03 14:07:37 +01:00
David Heinemeier Hansson
25e52d6c93 Fix escaping 2023-02-03 14:07:20 +01:00
David Heinemeier Hansson
2023c377ab Reboot if running 2023-02-03 13:52:31 +01:00
David Heinemeier Hansson
3bd2559c03 Version comes from config 2023-02-03 13:52:10 +01:00
David Heinemeier Hansson
ad26bce5a2 Add mocha for testing 2023-02-03 13:48:34 +01:00
David Heinemeier Hansson
aed7425b42 Streamline version handling 2023-02-03 13:21:11 +01:00
David Heinemeier Hansson
fadb73da39 Replace stub value 2023-02-03 13:20:10 +01:00
David Heinemeier Hansson
8024949fe7 Remove only specific container needed for rebooting 2023-02-03 13:20:03 +01:00
David Heinemeier Hansson
004c154abb Reset MRSK between invocations in CLI tests
Don't love having #reset, but whatever for now.
2023-02-03 13:15:14 +01:00
David Heinemeier Hansson
35b42cc885 Fix tests 2023-02-02 18:05:56 +01:00
David Heinemeier Hansson
6d80005f5d Run boot and console on relevant versions
Instead of just defaulting to local hash version
2023-02-02 18:05:03 +01:00
David Heinemeier Hansson
c8f673ef7c Add images command to see what's on the server for the service repository 2023-02-02 16:53:46 +01:00
David Heinemeier Hansson
212d5ec783 Merge pull request #31 from fschueller/accessory-class
Align config class name with file name
2023-02-02 15:50:50 +01:00
David Heinemeier Hansson
f88685a525 Extract CliTestCase 2023-02-02 15:37:41 +01:00
David Heinemeier Hansson
08908c3925 Fix test 2023-02-02 15:31:33 +01:00
David Heinemeier Hansson
48a9f599b8 It's all of them 2023-02-02 15:31:27 +01:00
David Heinemeier Hansson
7cc64299c8 Add app reboot 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
7494f08978 Cleanup 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
2b232b41ce Unbundle remove so parts can be triggered individually 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
c28065fd42 Fix doc 2023-02-02 15:28:36 +01:00
Farah Schüller
80b90ab689 Align config class name with file name
`Mrsk::Configuration::Assessory` -> `Mrsk::Configuration::Accessory` thus
aligning with the name of the file.
2023-02-02 12:44:48 +01:00
David Heinemeier Hansson
d71950f5e4 Merge pull request #30 from azolf/improve-test-coverage
Improve test coverage
2023-02-02 10:51:20 +01:00
Amirhosein Zolfaghari
5bf3c36001 added more test cases for traefik command 2023-02-01 11:53:25 +03:30
Amirhosein Zolfaghari
afb7b43f1a added registry command tests 2023-02-01 11:48:47 +03:30
Amirhosein Zolfaghari
4f57976efe ignore useless files 2023-02-01 11:48:47 +03:30
55 changed files with 1373 additions and 416 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.byebug_history .byebug_history
*.gem *.gem
coverage/*
.DS_Store

View File

@@ -4,4 +4,5 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec gemspec
gem "debug" gem "debug"
gem "mocha"
gem "railties" gem "railties"

View File

@@ -1,11 +1,12 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.4.0) mrsk (0.7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
dotenv (~> 2.8) dotenv (~> 2.8)
sshkit (~> 1.21) sshkit (~> 1.21)
thor (~> 1.2) thor (~> 1.2)
zeitwerk (~> 2.5)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
@@ -46,6 +47,8 @@ GEM
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
method_source (1.0.0) method_source (1.0.0)
minitest (5.17.0) minitest (5.17.0)
mocha (2.0.2)
ruby2_keywords (>= 0.0.5)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1) net-ssh (7.0.1)
@@ -74,6 +77,7 @@ GEM
rake (13.0.6) rake (13.0.6)
reline (0.3.2) reline (0.3.2)
io-console (~> 0.5) io-console (~> 0.5)
ruby2_keywords (0.0.5)
sshkit (1.21.3) sshkit (1.21.3)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
@@ -88,10 +92,12 @@ PLATFORMS
arm64-darwin-22 arm64-darwin-22
x86_64-darwin-20 x86_64-darwin-20
x86_64-darwin-21 x86_64-darwin-21
x86_64-darwin-22
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
debug debug
mocha
mrsk! mrsk!
railties railties

159
README.md
View File

@@ -1,10 +1,10 @@
# MRSK # MRSK
MRSK deploys Rails apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands. It was built for Rails applications, but works with any type of web app that can be bundled with Docker.
## Installation ## Installation
Install MRSK globally with `gem install mrsk`. Then, inside your app directory, run `mrsk install`. Now edit the new file `config/deploy.yml`. It could look as simple as this: Install MRSK globally with `gem install mrsk`. Then, inside your app directory, run `mrsk init` (or `mrsk init --bundle` within Rails apps where you want a bin/mrsk binstub). Now edit the new file `config/deploy.yml`. It could look as simple as this:
```yaml ```yaml
service: hey service: hey
@@ -15,12 +15,17 @@ servers:
registry: registry:
username: registry-user-name username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %> password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %>
env:
secret:
- RAILS_MASTER_KEY
``` ```
Now you're ready to deploy a multi-arch image to the servers: 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:
``` ```
MRSK_REGISTRY_PASSWORD=pw mrsk deploy mrsk deploy
``` ```
This will: This will:
@@ -32,17 +37,30 @@ This will:
5. Push the image to the registry. 5. Push the image to the registry.
6. Pull the image from the registry on the servers. 6. Pull the image from the registry on the servers.
7. Ensure Traefik is running and accepting traffic on port 80. 7. Ensure Traefik is running and accepting traffic on port 80.
8. Stop any containers running a previous versions of the app. 8. Ensure your app responds with `200 OK` to `GET /up`.
9. Start a new container with the version of the app that matches the current git version hash. 9. Stop any containers running a previous versions of the app.
10. Prune unused images and stopped containers to ensure servers don't fill up. 10. Start a new container with the version of the app that matches the current git version hash.
11. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them. Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## 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. 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 ## Configuration
@@ -68,10 +86,27 @@ registry:
### Using a different SSH user than root ### Using a different SSH user than root
The default SSH user is root, but you can change it using `ssh_user`: The default SSH user is root, but you can change it using `ssh/user`:
```yaml ```yaml
ssh_user: app ssh:
user: app
```
### Using a proxy SSH host
If you need to connect to server through a proxy host, you can use `ssh/proxy`:
```yaml
ssh:
proxy: "192.168.0.1" # defaults to root as the user
```
Or with specific user:
```yaml
ssh:
proxy: "app@192.168.0.1"
``` ```
### Using env variables ### Using env variables
@@ -149,10 +184,10 @@ You can specialize the default Traefik rules by setting labels on the containers
``` ```
labels: labels:
traefik.http.routers.hey.rule: '''Host(`app.hey.com`)''' traefik.http.routers.hey.rule: Host(\`app.hey.com\`)
``` ```
Note: The extra quotes are needed to ensure the rule is passed in correctly! Note: The escaped backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash!
This allows you to run multiple applications on the same server sharing the same Traefik instance and port. This allows you to run multiple applications on the same server sharing the same Traefik instance and port.
See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules. See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules.
@@ -225,14 +260,15 @@ builder:
This build secret can then be referenced in the Dockerfile: This build secret can then be referenced in the Dockerfile:
``` ```dockerfile
# Copy Gemfiles # Copy Gemfiles
COPY Gemfile Gemfile.lock ./ 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 \ RUN --mount=type=secret,id=GITHUB_TOKEN \
BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/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 ### Using command arguments for Traefik
@@ -241,12 +277,13 @@ You can customize the traefik command line:
```yaml ```yaml
traefik: traefik:
accesslog: true args:
accesslog.format: json accesslog: true
metrics.prometheus: true accesslog.format: json
metrics.prometheus.buckets: 0.1,0.3,1.2,5.0
``` ```
This will start the traefik container with `--accesslog=true accesslog.format=json`.
### Configuring build args for new images ### Configuring build args for new images
Build arguments that aren't secret can also be configured: Build arguments that aren't secret can also be configured:
@@ -265,14 +302,6 @@ ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION-slim as base FROM ruby:$RUBY_VERSION-slim as base
``` ```
### Using without RAILS_MASTER_KEY
If you're using MRSK with older Rails apps that predate RAILS_MASTER_KEY, or with a non-Rails app, you can skip the default usage and reference:
```yaml
skip_master_key: true
```
### Using accessories for database, cache, search services ### Using accessories for database, cache, search services
You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy: You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy:
@@ -300,11 +329,63 @@ 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. 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
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 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:
```yaml
audit_broadcast_cmd:
bin/audit_broadcast
```
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
```
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
```
### Using custom healthcheck path or port
MRSK defaults to checking the health of your application again `/up` on port 3000. You can tailor both with the `healthcheck` setting:
```yaml
healthcheck:
path: /healthz
port: 4000
```
This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
## Commands ## Commands
### Running remote execution and runners ### Running commands on servers
If you need to execute commands inside the Rails containers, you can use `mrsk app exec` and `mrsk app runner`. Examples: You can execute one-off commands on the servers:
```bash ```bash
# Runs command on all servers # Runs command on all servers
@@ -347,13 +428,25 @@ Database adapter sqlite3
Database schema version 20221231233303 Database schema version 20221231233303
# Run Rails runner on primary server # Run Rails runner on primary server
mrsk app runner -p 'puts Rails.application.config.time_zone' mrsk app exec -p 'bin/rails runner "puts Rails.application.config.time_zone"'
UTC UTC
``` ```
### Running a Rails console ### Running interactive commands over SSH
You can run interactive commands, like a Rails console or a bash session, on a server (default is primary, use `--hosts` to connect to another):
```bash
# Starts a bash session in a new container made from the most recent app image
mrsk app exec -i bash
# Starts a bash session in the currently running container for the app
mrsk app exec -i --reuse bash
# Starts a Rails console in a new container made from the most recent app image
mrsk app exec -i 'bin/rails console'
```
If you need to interact with the production console for the app, you can use `mrsk app console`, which will start a Rails console session on the primary host. You can start the console on a different host using `mrsk app console --host 192.168.0.2`. Be mindful that this is a live wire! Any changes made to the production database will take effect immeditately.
### Running details to see state of containers ### Running details to see state of containers

View File

@@ -3,8 +3,7 @@
# Prevent failures from being reported twice. # Prevent failures from being reported twice.
Thread.report_on_exception = false Thread.report_on_exception = false
require "dotenv/load" require "mrsk"
require "mrsk/cli"
begin begin
Mrsk::Cli::Main.start(ARGV) Mrsk::Cli::Main.start(ARGV)

View File

@@ -1,5 +1,9 @@
module Mrsk module Mrsk
end end
require "mrsk/version" require "zeitwerk"
require "mrsk/commander"
loader = Zeitwerk::Loader.for_gem
loader.ignore("#{__dir__}/mrsk/sshkit_with_ext.rb")
loader.setup
loader.eager_load # We need all commands loaded.

View File

@@ -1,9 +1,5 @@
require "mrsk"
module Mrsk::Cli module Mrsk::Cli
end end
# SSHKit uses instance eval, so we need a global const for ergonomics # SSHKit uses instance eval, so we need a global const for ergonomics
MRSK = Mrsk::Commander.new MRSK = Mrsk::Commander.new
require "mrsk/cli/main"

View File

@@ -1,5 +1,3 @@
require "mrsk/cli/base"
class Mrsk::Cli::Accessory < Mrsk::Cli::Base class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)" desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)"
def boot(name) def boot(name)
@@ -9,7 +7,13 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
directories(name) directories(name)
upload(name) upload(name)
on(accessory.host) { execute *accessory.run }
on(accessory.host) do
execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}"
end end
end end
end end
@@ -52,14 +56,20 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "start [NAME]", "Start existing accessory on host" desc "start [NAME]", "Start existing accessory on host"
def start(name) def start(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.start } on(accessory.host) do
execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start
end
end end
end end
desc "stop [NAME]", "Stop accessory on host" desc "stop [NAME]", "Stop accessory on host"
def stop(name) def stop(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.stop, raise_on_non_zero_exit: false } on(accessory.host) do
execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
end end
end end
@@ -82,39 +92,33 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
end end
end end
desc "exec [NAME] [CMD]", "Execute a custom command on accessory host" desc "exec [NAME] [CMD]", "Execute a custom command on servers"
option :method, aliases: "-m", default: "exec", desc: "Execution method: [exec] perform inside container / [run] perform in new container / [ssh] perform over ssh" 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) def exec(name, cmd)
runner = \
case options[:method]
when "exec" then "exec"
when "run" then "run_exec"
when "ssh_exec" then "exec_over_ssh"
when "ssh_run" then "run_over_ssh"
else raise "Unknown method: #{options[:method]}"
end.inquiry
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
if runner.exec_over_ssh? || runner.run_over_ssh? case
run_locally do when options[:interactive] && options[:reuse]
info "Launching command on #{accessory.host}" say "Launching interactive command with via SSH from existing container...", :magenta
exec accessory.send(runner, cmd) run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) }
end
else when options[:interactive]
say "Launching interactive command via SSH from new container...", :magenta
run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) }
when options[:reuse]
say "Launching command from existing container...", :magenta
on(accessory.host) do on(accessory.host) do
info "Launching command on #{accessory.host}" execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
execute *accessory.send(runner, cmd) capture_with_info(*accessory.execute_in_existing_container(cmd))
end end
end
end
end
desc "bash [NAME]", "Start a bash session on primary host (or specific host set by --hosts)" else
def bash(name) say "Launching command from new container...", :magenta
with_accessory(name) do |accessory| on(accessory.host) do
run_locally do execute *MRSK.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
info "Launching bash session on #{accessory.host}" capture_with_info(*accessory.execute_in_new_container(cmd))
exec accessory.bash end
end end
end end
end end
@@ -162,21 +166,29 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "remove_container [NAME]", "Remove accessory container from host" desc "remove_container [NAME]", "Remove accessory container from host"
def remove_container(name) def remove_container(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.remove_container } on(accessory.host) do
execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container
end
end end
end end
desc "remove_container [NAME]", "Remove accessory image from host" desc "remove_image [NAME]", "Remove accessory image from host"
def remove_image(name) def remove_image(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.remove_image } on(accessory.host) do
execute *MRSK.auditor.record("Removed #{name} accessory image"), verbosity: :debug
execute *accessory.remove_image
end
end end
end end
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host" desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host"
def remove_service_directory(name) def remove_service_directory(name)
with_accessory(name) do |accessory| with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.remove_service_directory } on(accessory.host) do
execute *accessory.remove_service_directory
end
end end
end end

View File

@@ -1,96 +1,106 @@
require "mrsk/cli/base"
class Mrsk::Cli::App < Mrsk::Cli::Base class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or start them if they've already been booted)" desc "boot", "Boot app on servers (or reboot app if already running)"
def boot def boot
MRSK.config.roles.each do |role| say "Get most recent version available as an image...", :magenta unless options[:version]
on(role.hosts) do |host| using_version(options[:version] || most_recent_version_available) do |version|
begin say "Start container with version #{version} (or reboot if already running)...", :magenta
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e MRSK.config.roles.each do |role|
if e.message =~ /already in use/ on(role.hosts) do |host|
error "Container with same version already deployed on #{host}, starting that instead" execute *MRSK.auditor.record("Booted app version #{version}"), verbosity: :debug
execute *MRSK.app.start, host: host
else begin
raise 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
execute *MRSK.app.remove_container(version: version)
execute *MRSK.app.run(role: role.name)
else
raise
end
end end
end end
end end
end end
end end
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)" desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)"
option :version, desc: "Defaults to the most recent git-hash in local repository"
def start def start
if (version = options[:version]).present? on(MRSK.hosts) do
on(MRSK.hosts) { execute *MRSK.app.start(version: version) } execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug
else execute *MRSK.app.start, raise_on_non_zero_exit: false
on(MRSK.hosts) { execute *MRSK.app.start, raise_on_non_zero_exit: false }
end end
end end
desc "stop", "Stop app on servers" desc "stop", "Stop app on servers"
def stop def stop
on(MRSK.hosts) { execute *MRSK.app.stop, raise_on_non_zero_exit: false } on(MRSK.hosts) do
execute *MRSK.auditor.record("Stopped app"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false
end
end end
desc "details", "Display details about app containers" desc "details", "Display details about app containers"
def details def details
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) }
end end
desc "exec [CMD]", "Execute a custom command on servers" desc "exec [CMD]", "Execute a custom command on servers"
option :method, aliases: "-m", default: "exec", desc: "Execution method: [exec] perform inside app container / [run] perform in new container / [ssh] perform over ssh" 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) def exec(cmd)
runner = \ case
case options[:method] when options[:interactive] && options[:reuse]
when "exec" then "exec" say "Get current version of running container...", :magenta unless options[:version]
when "run" then "run_exec" using_version(options[:version] || current_running_version) do |version|
when "ssh" then "exec_over_ssh" say "Launching interactive command with version #{version} via SSH from existing container on #{MRSK.primary_host}...", :magenta
else raise "Unknown method: #{options[:method]}" run_locally { exec MRSK.app.execute_in_existing_container_over_ssh(cmd, host: MRSK.primary_host) }
end.inquiry
if runner.exec_over_ssh?
run_locally do
info "Launching command on #{MRSK.primary_host}"
exec MRSK.app.exec_over_ssh(cmd, host: MRSK.primary_host)
end end
when options[:interactive]
say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(options[:version] || most_recent_version_available) do |version|
say "Launching interactive command with version #{version} via SSH from new container on #{MRSK.primary_host}...", :magenta
run_locally { exec MRSK.app.execute_in_new_container_over_ssh(cmd, host: MRSK.primary_host) }
end
when options[:reuse]
say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version|
say "Launching command with version #{version} from existing container...", :magenta
on(MRSK.hosts) do |host|
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
else else
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.send(runner, cmd)) } say "Get most recent version available as an image...", :magenta unless options[:version]
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("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
end end
end end
desc "console", "Start Rails Console on primary host (or specific host set by --hosts)"
def console
run_locally do
info "Launching Rails console on #{MRSK.primary_host}"
exec MRSK.app.console(host: MRSK.primary_host)
end
end
desc "bash", "Start a bash session on primary host (or specific host set by --hosts)"
def bash
run_locally do
info "Launching bash session on #{MRSK.primary_host}"
exec MRSK.app.bash(host: MRSK.primary_host)
end
end
desc "runner [EXPRESSION]", "Execute Rails runner with given expression"
def runner(expression)
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.exec("bin/rails", "runner", "'#{expression}'")) }
end
desc "containers", "List all the app containers currently on servers" desc "containers", "List all the app containers currently on servers"
def containers def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end end
desc "current", "Return the current running container ID" desc "images", "List all the app images currently on servers"
def current def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_container_id) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end end
desc "logs", "Show lines from app on servers" desc "logs", "Show 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 :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
@@ -122,16 +132,64 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
desc "remove", "Remove app containers and images from servers" desc "remove", "Remove app containers and images from servers"
option :only, default: "", desc: "Use 'containers' or 'images'"
def remove def remove
case options[:only] remove_containers
when "containers" remove_images
on(MRSK.hosts) { execute *MRSK.app.remove_containers } end
when "images"
on(MRSK.hosts) { execute *MRSK.app.remove_images } desc "remove_container [VERSION]", "Remove app container with given version from servers"
else def remove_container(version)
on(MRSK.hosts) { execute *MRSK.app.remove_containers } on(MRSK.hosts) do
on(MRSK.hosts) { execute *MRSK.app.remove_images } execute *MRSK.auditor.record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
end end
end end
desc "remove_containers", "Remove all app containers from servers"
def remove_containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app containers"), verbosity: :debug
execute *MRSK.app.remove_containers
end
end
desc "remove_images", "Remove all app images from servers"
def remove_images
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images
end
end
desc "current_version", "Shows the version currently running"
def current_version
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip }
end
private
def using_version(new_version)
if new_version
begin
old_version = MRSK.config.version
MRSK.config.version = new_version
yield new_version
ensure
MRSK.config.version = old_version
end
else
yield MRSK.config.version
end
end
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
end
def current_running_version(host: MRSK.primary_host)
version = nil
on(host) { version = capture_with_info(*MRSK.app.current_running_version).strip }
version.presence
end
end end

View File

@@ -1,4 +1,5 @@
require "thor" require "thor"
require "dotenv"
require "mrsk/sshkit_with_ext" require "mrsk/sshkit_with_ext"
module Mrsk::Cli module Mrsk::Cli
@@ -21,10 +22,19 @@ module Mrsk::Cli
def initialize(*) def initialize(*)
super super
load_envs
initialize_commander(options) initialize_commander(options)
end end
private private
def load_envs
if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env")
else
Dotenv.load(".env")
end
end
def initialize_commander(options) def initialize_commander(options)
MRSK.tap do |commander| MRSK.tap do |commander|
commander.config_file = Pathname.new(File.expand_path(options[:config_file])) commander.config_file = Pathname.new(File.expand_path(options[:config_file]))
@@ -49,9 +59,14 @@ module Mrsk::Cli
def print_runtime def print_runtime
started_at = Time.now started_at = Time.now
yield yield
return Time.now - started_at
ensure ensure
runtime = Time.now - started_at runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}" puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
end end
end end

View File

@@ -1,5 +1,3 @@
require "mrsk/cli/base"
class Mrsk::Cli::Build < Mrsk::Cli::Base class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Deliver a newly built app image to servers" desc "deliver", "Deliver a newly built app image to servers"
def deliver def deliver
@@ -11,7 +9,7 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
def push def push
cli = self cli = self
run_locally do run_locally do
begin begin
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
@@ -30,7 +28,10 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "pull", "Pull app image from the registry onto servers" desc "pull", "Pull app image from the registry onto servers"
def pull def pull
on(MRSK.hosts) { execute *MRSK.builder.pull } on(MRSK.hosts) do
execute *MRSK.auditor.record("Pulled image with version #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.pull
end
end end
desc "create", "Create a local build setup" desc "create", "Create a local build setup"

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

@@ -1,13 +1,3 @@
require "mrsk/cli/base"
require "mrsk/cli/accessory"
require "mrsk/cli/app"
require "mrsk/cli/build"
require "mrsk/cli/prune"
require "mrsk/cli/registry"
require "mrsk/cli/server"
require "mrsk/cli/traefik"
class Mrsk::Cli::Main < Mrsk::Cli::Base class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy the app to servers" desc "setup", "Setup all accessories and deploy the app to servers"
def setup def setup
@@ -20,31 +10,61 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "deploy", "Deploy the app to servers" desc "deploy", "Deploy the app to servers"
def deploy def deploy
print_runtime do runtime = print_runtime do
say "Ensure Docker is installed...", :magenta
invoke "mrsk:cli:server:bootstrap" invoke "mrsk:cli:server:bootstrap"
say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login" invoke "mrsk:cli:registry:login"
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:build:deliver"
say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot" invoke "mrsk:cli:traefik:boot"
invoke "mrsk:cli:app:stop"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all" invoke "mrsk:cli:prune:all"
end end
audit_broadcast "Deployed app in #{runtime.to_i} seconds"
end end
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)" desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def redeploy def redeploy
print_runtime do runtime = print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:app:stop"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
end end
audit_broadcast "Redeployed app in #{runtime.to_i} seconds"
end end
desc "rollback [VERSION]", "Rollback the app to VERSION (that must already be on servers)" desc "rollback [VERSION]", "Rollback the app to VERSION"
def rollback(version) def rollback(version)
on(MRSK.hosts) do MRSK.version = version
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start(version: version) if container_name_available?(MRSK.config.service_with_version)
say "Stop current version, then start version #{version}...", :magenta
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
end end
@@ -55,6 +75,13 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
invoke "mrsk:cli:accessory:details", [ "all" ] invoke "mrsk:cli:accessory:details", [ "all" ]
end end
desc "audit", "Show audit log from servers"
def audit
on(MRSK.hosts) do |host|
puts_by_host host, capture_with_info(*MRSK.auditor.reveal)
end
end
desc "config", "Show combined config" desc "config", "Show combined config"
def config def config
run_locally do run_locally do
@@ -62,22 +89,29 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end end
end end
desc "install", "Create config stub in config/deploy.yml and binstub in bin/mrsk" desc "init", "Create config stub in config/deploy.yml and env stub in .env"
option :skip_binstub, type: :boolean, default: false, desc: "Skip adding MRSK to the Gemfile and creating bin/mrsk binstub" option :bundle, type: :boolean, default: false, desc: "Add MRSK to the Gemfile and create a bin/mrsk binstub"
def install def init
require "fileutils" require "fileutils"
if (deploy_file = Pathname.new(File.expand_path("config/deploy.yml"))).exist? if (deploy_file = Pathname.new(File.expand_path("config/deploy.yml"))).exist?
puts "Config file already exists in config/deploy.yml (remove first to create a new one)" puts "Config file already exists in config/deploy.yml (remove first to create a new one)"
else else
FileUtils.mkdir_p deploy_file.dirname
FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file
puts "Created configuration file in config/deploy.yml" puts "Created configuration file in config/deploy.yml"
end end
unless options[:skip_binstub] unless (deploy_file = Pathname.new(File.expand_path(".env"))).exist?
FileUtils.cp_r Pathname.new(File.expand_path("templates/template.env", __dir__)), deploy_file
puts "Created .env file"
end
if options[:bundle]
if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist? if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)" puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else else
puts "Adding MRSK to Gemfile and bundle..."
`bundle add mrsk` `bundle add mrsk`
`bundle binstubs mrsk` `bundle binstubs mrsk`
puts "Created binstub file in bin/mrsk" puts "Created binstub file in bin/mrsk"
@@ -85,6 +119,19 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
end end
end end
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
def envify
if destination = options[:destination]
env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else
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" desc "remove", "Remove Traefik, app, and registry session from servers"
def remove def remove
invoke "mrsk:cli:traefik:remove" invoke "mrsk:cli:traefik:remove"
@@ -106,6 +153,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "build", "Build the application image" desc "build", "Build the application image"
subcommand "build", Mrsk::Cli::Build subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck the application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
desc "prune", "Prune old application images and containers" desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune subcommand "prune", Mrsk::Cli::Prune
@@ -117,4 +167,11 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "traefik", "Manage the Traefik load balancer" desc "traefik", "Manage the Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik subcommand "traefik", Mrsk::Cli::Traefik
private
def container_name_available?(container_name, host: MRSK.primary_host)
container_names = nil
on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") }
Array(container_names).include?(container_name)
end
end end

View File

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

View File

@@ -1,9 +1,7 @@
require "mrsk/cli/base"
class Mrsk::Cli::Registry < Mrsk::Cli::Base class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Login to the registry locally and remotely" desc "login", "Login to the registry locally and remotely"
def login def login
run_locally { execute *MRSK.registry.login } run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login } on(MRSK.hosts) { execute *MRSK.registry.login }
rescue ArgumentError => e rescue ArgumentError => e
puts e.message puts e.message

View File

@@ -1,5 +1,3 @@
require "mrsk/cli/base"
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure Docker is installed on the servers" desc "bootstrap", "Ensure Docker is installed on the servers"
def bootstrap def bootstrap

View File

@@ -0,0 +1,2 @@
MRSK_REGISTRY_PASSWORD=change-this
RAILS_MASTER_KEY=another-env

View File

@@ -1,5 +1,3 @@
require "mrsk/cli/base"
class Mrsk::Cli::Traefik < Mrsk::Cli::Base class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "boot", "Boot Traefik on servers" desc "boot", "Boot Traefik on servers"
def boot def boot
@@ -15,12 +13,18 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "start", "Start existing Traefik on servers" desc "start", "Start existing Traefik on servers"
def start def start
on(MRSK.traefik_hosts) { execute *MRSK.traefik.start, raise_on_non_zero_exit: false } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false
end
end end
desc "stop", "Stop Traefik on servers" desc "stop", "Stop Traefik on servers"
def stop def stop
on(MRSK.traefik_hosts) { execute *MRSK.traefik.stop, raise_on_non_zero_exit: false } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
end
end end
desc "restart", "Restart Traefik on servers" desc "restart", "Restart Traefik on servers"
@@ -67,11 +71,17 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove_container", "Remove Traefik container from servers" desc "remove_container", "Remove Traefik container from servers"
def remove_container def remove_container
on(MRSK.traefik_hosts) { execute *MRSK.traefik.remove_container } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end end
desc "remove_container", "Remove Traefik image from servers" desc "remove_container", "Remove Traefik image from servers"
def remove_image def remove_image
on(MRSK.traefik_hosts) { execute *MRSK.traefik.remove_image } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end end
end end

View File

@@ -1,13 +1,5 @@
require "active_support/core_ext/enumerable" require "active_support/core_ext/enumerable"
require "mrsk/configuration"
require "mrsk/commands/accessory"
require "mrsk/commands/app"
require "mrsk/commands/builder"
require "mrsk/commands/prune"
require "mrsk/commands/traefik"
require "mrsk/commands/registry"
class Mrsk::Commander class Mrsk::Commander
attr_accessor :config_file, :destination, :verbosity, :version attr_accessor :config_file, :destination, :verbosity, :version
@@ -77,18 +69,45 @@ class Mrsk::Commander
Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end end
def auditor
@auditor ||= Mrsk::Commands::Auditor.new(config)
end
def with_verbosity(level) def healthcheck
old_level = SSHKit.config.output_verbosity @healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def with_verbosity(level)
old_level = self.verbosity
self.verbosity = level
SSHKit.config.output_verbosity = level SSHKit.config.output_verbosity = level
yield yield
ensure ensure
self.verbosity = old_level
SSHKit.config.output_verbosity = old_level SSHKit.config.output_verbosity = old_level
end end
# Test-induced damage!
def reset
@config = @config_file = @destination = @version = nil
@app = @builder = @traefik = @registry = @prune = @auditor = nil
@verbosity = :info
end
private private
def cascading_version 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 end
# Lazy setup of SSHKit # Lazy setup of SSHKit

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/base"
class Mrsk::Commands::Accessory < Mrsk::Commands::Base class Mrsk::Commands::Accessory < Mrsk::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config
@@ -10,10 +8,11 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def run def run
docker :run, docker :run,
"--name", service_name, "--name", service_name,
"-d", "-d",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p", port, "-p", port,
*env_args, *env_args,
*volume_args, *volume_args,
@@ -33,6 +32,7 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"),
@@ -40,20 +40,21 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
end end
def follow_logs(grep: nil) def follow_logs(grep: nil)
run_over_ssh pipe( run_over_ssh \
docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"), pipe \
(%(grep "#{grep}") if grep) docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"),
).join(" ") (%(grep "#{grep}") if grep)
end end
def exec(*command, interactive: false)
def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
service_name, service_name,
*command *command
end end
def run_exec(*command, interactive: false) def execute_in_new_container(*command, interactive: false)
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", "--rm",
@@ -63,17 +64,18 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
*command *command
end end
def execute_in_existing_container_over_ssh(*command)
run_over_ssh execute_in_existing_container(*command, interactive: true)
end
def execute_in_new_container_over_ssh(*command)
run_over_ssh execute_in_new_container(*command, interactive: true)
end
def run_over_ssh(command) def run_over_ssh(command)
super command, host: host super command, host: host
end end
def exec_over_ssh(*command)
run_over_ssh run_exec(*command, interactive: true).join(" ")
end
def bash
exec_over_ssh "bash"
end
def ensure_local_file_present(local_file) def ensure_local_file_present(local_file)
if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist? if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/base"
class Mrsk::Commands::App < Mrsk::Commands::Base class Mrsk::Commands::App < Mrsk::Commands::Base
def run(role: :web) def run(role: :web)
role = config.role(role) role = config.role(role)
@@ -7,8 +5,8 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :run, docker :run,
"-d", "-d",
"--restart unless-stopped", "--restart unless-stopped",
"--name", config.service_with_version, "--log-opt", "max-size=#{MAX_LOG_SIZE}",
*rails_master_key_arg, "--name", service_with_version,
*role.env_args, *role.env_args,
*config.volume_args, *config.volume_args,
*role.label_args, *role.label_args,
@@ -16,22 +14,19 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
role.cmd role.cmd
end end
def start(version: config.version) def start
docker :start, "#{config.service}-#{version}" docker :start, service_with_version
end
def current_container_id
docker :ps, "-q", *service_filter
end end
def stop def stop
pipe current_container_id, "xargs docker stop" pipe current_container_id, xargs(docker(:stop))
end end
def info def info
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
current_container_id, current_container_id,
@@ -39,66 +34,105 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def exec(*command, interactive: false) def follow_logs(host:, grep: nil)
run_over_ssh \
pipe(
current_container_id,
"xargs docker logs -t -n 10 -f 2>&1",
(%(grep "#{grep}") if grep)
),
host: host
end
def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
config.service_with_version, config.service_with_version,
*command *command
end end
def run_exec(*command, interactive: false) def execute_in_new_container(*command, interactive: false)
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", "--rm",
*rails_master_key_arg,
*config.env_args, *config.env_args,
*config.volume_args, *config.volume_args,
config.absolute_image, config.absolute_image,
*command *command
end end
def exec_over_ssh(*command, host:) def execute_in_existing_container_over_ssh(*command, host:)
run_over_ssh run_exec(*command, interactive: true).join(" "), host: host run_over_ssh execute_in_existing_container(*command, interactive: true), host: host
end end
def follow_logs(host:, grep: nil) def execute_in_new_container_over_ssh(*command, host:)
run_over_ssh pipe( run_over_ssh execute_in_new_container(*command, interactive: true), host: host
current_container_id,
"xargs docker logs -t -n 10 -f 2>&1",
(%(grep "#{grep}") if grep)
).join(" "), host: host
end end
def console(host:)
exec_over_ssh "bin/rails", "c", host: host def current_container_id
docker :ps, "-q", *service_filter
end end
def bash(host:) def current_running_version
exec_over_ssh "bash", host: host # FIXME: Find more graceful way to extract the version from "app-version" than using sed and tail!
pipe \
docker(:ps, "--filter", "label=service=#{config.service}", "--format", '"{{.Names}}"'),
%(sed 's/-/\\n/g'),
"tail -n 1"
end end
def most_recent_version_from_available_images
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"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 def list_containers
docker :container, :ls, "-a", *service_filter docker :container, :ls, "-a", *service_filter
end end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
end
def remove_container(version:)
pipe \
container_id_for(container_name: service_with_version(version)),
xargs(docker(:container, :rm))
end
def remove_containers def remove_containers
docker :container, :prune, "-f", *service_filter docker :container, :prune, "-f", *service_filter
end end
def list_images
docker :image, :ls, config.repository
end
def remove_images def remove_images
docker :image, :prune, "-a", "-f", *service_filter docker :image, :prune, "-a", "-f", *service_filter
end end
private private
def service_with_version(version = nil)
if version
"#{config.service}-#{version}"
else
config.service_with_version
end
end
def service_filter def service_filter
[ "--filter", "label=service=#{config.service}" ] [ "--filter", "label=service=#{config.service}" ]
end end
def rails_master_key_arg
if master_key = config.master_key
[ "-e", redact("RAILS_MASTER_KEY=#{master_key}") ]
else
[]
end
end
end end

View File

@@ -0,0 +1,44 @@
require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
# Runs remotely
def record(line)
append \
[ :echo, tagged_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
end
end
def reveal
[ :tail, "-n", 50, audit_log_file ]
end
private
def audit_log_file
"mrsk-#{config.service}-audit.log"
end
def tagged_line(line)
"'#{tags} #{line}'"
end
def tags
"[#{recorded_at}] [#{performer}]"
end
def performer
@performer ||= `whoami`.strip
end
def recorded_at
Time.now.to_fs(:db)
end
end

View File

@@ -2,14 +2,23 @@ module Mrsk::Commands
class Base class Base
delegate :redact, to: Mrsk::Utils delegate :redact, to: Mrsk::Utils
MAX_LOG_SIZE = "10m"
attr_accessor :config attr_accessor :config
def initialize(config) def initialize(config)
@config = config @config = config
end end
def run_over_ssh(command, host:) def run_over_ssh(*command, host:)
"ssh -t #{config.ssh_user}@#{host} '#{command}'" "ssh".tap do |cmd|
cmd << " -J #{config.ssh_proxy.jump_proxies}" if config.ssh_proxy
cmd << " -t #{config.ssh_user}@#{host} '#{command.join(" ")}'"
end
end
def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q"
end end
private private
@@ -28,6 +37,14 @@ module Mrsk::Commands
combine *commands, by: "|" combine *commands, by: "|"
end end
def append(*commands)
combine *commands, by: ">>"
end
def xargs(command)
[ :xargs, command ].flatten
end
def docker(*args) def docker(*args)
args.compact.unshift :docker args.compact.unshift :docker
end end

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/base"
class Mrsk::Commands::Builder < Mrsk::Commands::Base class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :pull, :info, to: :target delegate :create, :remove, :push, :pull, :info, to: :target
@@ -36,8 +34,3 @@ class Mrsk::Commands::Builder < Mrsk::Commands::Base
@multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config) @multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config)
end end
end end
require "mrsk/commands/builder/native"
require "mrsk/commands/builder/native/remote"
require "mrsk/commands/builder/multiarch"
require "mrsk/commands/builder/multiarch/remote"

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/base"
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils delegate :argumentize, to: Mrsk::Utils
@@ -7,15 +5,27 @@ class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
docker :pull, config.absolute_image docker :pull, config.absolute_image
end end
def build_args def build_options
argumentize "--build-arg", args, redacted: true [ *build_tags, *build_labels, *build_args, *build_secrets ]
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end end
private 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 def args
(config.builder && config.builder["args"]) || {} (config.builder && config.builder["args"]) || {}
end end

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/builder/base"
class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
def create def create
docker :buildx, :create, "--use", "--name", builder_name docker :buildx, :create, "--use", "--name", builder_name
@@ -14,9 +12,7 @@ class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
"--push", "--push",
"--platform", "linux/amd64,linux/arm64", "--platform", "linux/amd64,linux/arm64",
"--builder", builder_name, "--builder", builder_name,
"-t", config.absolute_image, *build_options,
*build_args,
*build_secrets,
"." "."
end end

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/builder/multiarch"
class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Multiarch class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Multiarch
def create def create
combine \ combine \

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/builder/base"
class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def create def create
# No-op on native # No-op on native
@@ -11,7 +9,7 @@ class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def push def push
combine \ combine \
docker(:build, "-t", *build_args, *build_secrets, config.absolute_image, "."), docker(:build, *build_options, "."),
docker(:push, config.absolute_image) docker(:push, config.absolute_image)
end end

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/builder/native"
class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
def create def create
chain \ chain \
@@ -18,9 +16,7 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
"--push", "--push",
"--platform", platform, "--platform", platform,
"--builder", builder_name, "--builder", builder_name,
"-t", config.absolute_image, *build_options,
*build_args,
*build_secrets,
"." "."
end 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

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

View File

@@ -1,5 +1,3 @@
require "mrsk/commands/base"
class Mrsk::Commands::Registry < Mrsk::Commands::Base class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config delegate :registry, to: :config

View File

@@ -1,10 +1,9 @@
require "mrsk/commands/base"
class Mrsk::Commands::Traefik < Mrsk::Commands::Base class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run def run
docker :run, "--name traefik", docker :run, "--name traefik",
"-d", "-d",
"--restart unless-stopped", "--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"-p 80:80", "-p 80:80",
"-v /var/run/docker.sock:/var/run/docker.sock", "-v /var/run/docker.sock:/var/run/docker.sock",
"traefik", "traefik",

View File

@@ -3,12 +3,13 @@ require "active_support/core_ext/string/inquiry"
require "active_support/core_ext/module/delegation" require "active_support/core_ext/module/delegation"
require "pathname" require "pathname"
require "erb" require "erb"
require "mrsk/utils" require "net/ssh/proxy/jump"
class Mrsk::Configuration class Mrsk::Configuration
delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :raw_config, allow_nil: true delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :version
attr_accessor :raw_config attr_accessor :raw_config
class << self class << self
@@ -52,7 +53,7 @@ class Mrsk::Configuration
end end
def accessories def accessories
@accessories ||= raw_config.accessories&.keys&.collect { |name| Mrsk::Configuration::Assessory.new(name, config: self) } || [] @accessories ||= raw_config.accessories&.keys&.collect { |name| Mrsk::Configuration::Accessory.new(name, config: self) } || []
end end
def accessory(name) def accessory(name)
@@ -73,10 +74,6 @@ class Mrsk::Configuration
end end
def version
@version
end
def repository def repository
[ raw_config.registry["server"], image ].compact.join("/") [ raw_config.registry["server"], image ].compact.join("/")
end end
@@ -85,6 +82,10 @@ class Mrsk::Configuration
"#{repository}:#{version}" "#{repository}:#{version}"
end end
def latest_image
"#{repository}:latest"
end
def service_with_version def service_with_version
"#{service}-#{version}" "#{service}-#{version}"
end end
@@ -106,18 +107,33 @@ class Mrsk::Configuration
end end
end end
def ssh_user def ssh_user
raw_config.ssh_user || "root" if raw_config.ssh.present?
raw_config.ssh["user"] || "root"
else
"root"
end
end
def ssh_proxy
if raw_config.ssh.present? && raw_config.ssh["proxy"]
Net::SSH::Proxy::Jump.new \
raw_config.ssh["proxy"].include?("@") ? raw_config.ssh["proxy"] : "root@#{raw_config.ssh["proxy"]}"
end
end end
def ssh_options def ssh_options
{ user: ssh_user, auth_methods: [ "publickey" ] } { user: ssh_user, proxy: ssh_proxy, auth_methods: [ "publickey" ] }.compact
end end
def master_key
unless raw_config.skip_master_key def audit_broadcast_cmd
ENV["RAILS_MASTER_KEY"] || File.read(Pathname.new(File.expand_path("config/master.key"))) raw_config.audit_broadcast_cmd
end end
def healthcheck
{ "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {})
end end
@@ -139,7 +155,8 @@ class Mrsk::Configuration
volume_args: volume_args, volume_args: volume_args,
ssh_options: ssh_options, ssh_options: ssh_options,
builder: raw_config.builder, builder: raw_config.builder,
accessories: raw_config.accessories accessories: raw_config.accessories,
healthcheck: healthcheck
}.compact }.compact
end end
@@ -174,6 +191,3 @@ class Mrsk::Configuration
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end end
end end
require "mrsk/configuration/role"
require "mrsk/configuration/accessory"

View File

@@ -1,4 +1,4 @@
class Mrsk::Configuration::Assessory class Mrsk::Configuration::Accessory
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :name, :specifics attr_accessor :name, :specifics

View File

@@ -59,7 +59,7 @@ class Mrsk::Configuration::Role
if running_traefik? if running_traefik?
{ {
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'", "traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => config.healthcheck["path"],
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s", "traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3", "traefik.http.middlewares.#{config.service}.retry.attempts" => "3",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms" "traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms"
@@ -96,7 +96,12 @@ class Mrsk::Configuration::Role
def merged_env_with_secrets def merged_env_with_secrets
merged_env.tap do |new_env| merged_env.tap do |new_env|
new_env["secret"] = Array(config.env["secret"]) + Array(specialized_env["secret"]) new_env["secret"] = Array(config.env["secret"]) + Array(specialized_env["secret"])
new_env["clear"] = (Array(config.env["clear"] || config.env) + Array(specialized_env["clear"] || specialized_env)).uniq
# If there's no secret/clear split, everything is clear
clear_app_env = config.env["secret"] ? Array(config.env["clear"]) : Array(config.env["clear"] || config.env)
clear_role_env = specialized_env["secret"] ? Array(specialized_env["clear"]) : Array(specialized_env["clear"] || specialized_env)
new_env["clear"] = (clear_app_env + clear_role_env).uniq
end end
end end
end end

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.4.0" VERSION = "0.7.0"
end end

View File

@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
spec.authors = [ "David Heinemeier Hansson" ] spec.authors = [ "David Heinemeier Hansson" ]
spec.email = "dhh@hey.com" spec.email = "dhh@hey.com"
spec.homepage = "https://github.com/rails/mrsk" 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.license = "MIT"
spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"] spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"]
@@ -16,4 +16,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "sshkit", "~> 1.21" spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "thor", "~> 1.2" spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8" spec.add_dependency "dotenv", "~> 2.8"
spec.add_dependency "zeitwerk", "~> 2.5"
end end

View File

@@ -1,34 +1,38 @@
require "test_helper" require_relative "cli_test_case"
require "active_support/testing/stream"
require "mrsk/cli"
class CliAccessoryTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup { ENV["MYSQL_ROOT_PASSWORD"] = "secret123" }
teardown { ENV["MYSQL_ROOT_PASSWORD"] = nil }
class CliAccessoryTest < CliTestCase
test "upload" do test "upload" do
command = stdouted { Mrsk::Cli::Accessory.start(["upload", "mysql", "-c", "test/fixtures/deploy_with_accessories.yml"]) } assert_match "test/fixtures/files/my.cnf app-mysql/etc/mysql/my.cnf", run_command("upload", "mysql")
assert_match "test/fixtures/files/my.cnf app-mysql/etc/mysql/my.cnf", command
end end
test "directories" do test "directories" do
command = stdouted { Mrsk::Cli::Accessory.start(["directories", "mysql", "-c", "test/fixtures/deploy_with_accessories.yml"]) } assert_match "mkdir -p $PWD/app-mysql/data", run_command("directories", "mysql")
assert_match "mkdir -p $PWD/app-mysql/data", command
end end
test "remove service direcotry" do test "remove service direcotry" do
command = stdouted { Mrsk::Cli::Accessory.start(["remove_service_directory", "mysql", "-c", "test/fixtures/deploy_with_accessories.yml"]) } assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql")
assert_match "rm -rf app-mysql", command
end end
test "boot" do test "boot" do
command = stdouted { Mrsk::Cli::Accessory.start(["boot", "mysql", "-c", "test/fixtures/deploy_with_accessories.yml"]) } 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 -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", command
end end
test "exec" do
run_command("exec", "mysql", "mysql -v").tap do |output|
assert_match /Launching command from new container/, output
assert_match /mysql -v/, output
end
end
test "exec with reuse" do
run_command("exec", "mysql", "--reuse", "mysql -v").tap do |output|
assert_match /Launching command from existing container/, output
assert_match %r[docker exec app-mysql mysql -v], output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end end

67
test/cli/app_test.rb Normal file
View File

@@ -0,0 +1,67 @@
require_relative "cli_test_case"
class CliAppTest < CliTestCase
test "boot" do
assert_match /Running docker run -d --restart unless-stopped/, run_command("boot")
end
test "boot will reboot if same version is already running" do
run_command("details") # Preheat MRSK const
# 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 ])
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 /docker run/, output # Start new container
end
ensure
Thread.report_on_exception = true
end
test "start" do
run_command("start").tap do |output|
assert_match /docker start app-999/, output
end
end
test "stop" do
run_command("stop").tap do |output|
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output
end
end
test "details" do
run_command("details").tap do |output|
assert_match /docker ps --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
end
end
test "exec" do
run_command("exec", "ruby -v").tap do |output|
assert_match /ruby -v/, output
end
end
test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match %r[docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1], output # Get current version
assert_match %r[docker exec app-999 ruby -v], output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

24
test/cli/cli_test_case.rb Normal file
View File

@@ -0,0 +1,24 @@
require "test_helper"
require "active_support/testing/stream"
class CliTestCase < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do
ENV["VERSION"] = "999"
ENV["RAILS_MASTER_KEY"] = "123"
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end
teardown do
ENV.delete("RAILS_MASTER_KEY")
ENV.delete("MYSQL_ROOT_PASSWORD")
ENV.delete("VERSION")
MRSK.reset
end
private
def stdouted
capture(:stdout) { yield }.strip
end
end

View File

@@ -1,15 +1,33 @@
require "test_helper" require_relative "cli_test_case"
require "active_support/testing/stream"
require "mrsk/cli"
class CliMainTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do
end
class CliMainTest < CliTestCase
test "version" do test "version" do
version = stdouted { Mrsk::Cli::Main.new.version } version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version assert_equal Mrsk::VERSION, version
end end
test "rollback bad version" do
run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output|
assert_match /docker container ls -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 end

View File

@@ -1,5 +1,4 @@
require "test_helper" require "test_helper"
require "mrsk/commander"
class CommanderTest < ActiveSupport::TestCase class CommanderTest < ActiveSupport::TestCase
setup do setup do
@@ -10,6 +9,16 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal Mrsk::Configuration, @mrsk.config.class assert_equal Mrsk::Configuration, @mrsk.config.class
end 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 test "overwriting hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts

View File

@@ -1,10 +1,8 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/accessory"
class CommandsAccessoryTest < ActiveSupport::TestCase class CommandsAccessoryTest < ActiveSupport::TestCase
setup do setup do
@config = { @config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ], servers: [ "1.1.1.1" ],
accessories: { accessories: {
@@ -41,46 +39,96 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config = Mrsk::Configuration.new(@config) @config = Mrsk::Configuration.new(@config)
@mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql) @mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql)
@redis = Mrsk::Commands::Accessory.new(@config, name: :redis) @redis = Mrsk::Commands::Accessory.new(@config, name: :redis)
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end
teardown do
ENV.delete("MYSQL_ROOT_PASSWORD")
end end
test "run" do test "run" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" 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",
@mysql.run.join(" ")
assert_equal \ 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-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(" ")
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
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end end
test "start" do test "start" do
assert_equal [:docker, :container, :start, "app-mysql"], @mysql.start assert_equal \
"docker container start app-mysql",
@mysql.start.join(" ")
end end
test "stop" do test "stop" do
assert_equal [:docker, :container, :stop, "app-mysql"], @mysql.stop assert_equal \
"docker container stop app-mysql",
@mysql.stop.join(" ")
end end
test "info" do 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 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").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").join(" ")
end
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|,
@mysql.execute_in_new_container_over_ssh("mysql", "-u", "root")
end
end
test "execute in existing container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
assert_match %r|docker exec -it app-mysql mysql -u root|,
@mysql.execute_in_existing_container_over_ssh("mysql", "-u", "root")
end
end
test "logs" do test "logs" do
assert_equal [:docker, :logs, "app-mysql", "-t", "2>&1"], @mysql.logs assert_equal \
assert_equal [:docker, :logs, "app-mysql", " --since 5m", " -n 100", "-t", "2>&1", "|", "grep 'thing'"], @mysql.logs(since: "5m", lines: 100, grep: "thing") "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 end
test "follow logs" do 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 end
test "remove container" do 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 end
test "remove image" do 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
end end

View File

@@ -1,41 +1,169 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/app"
class CommandsAppTest < ActiveSupport::TestCase class CommandsAppTest < ActiveSupport::TestCase
setup do setup do
ENV["RAILS_MASTER_KEY"] = "456" ENV["RAILS_MASTER_KEY"] = "456"
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config) @app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config).tap { |c| c.version = "999" }
end end
teardown do teardown do
ENV["RAILS_MASTER_KEY"] = nil ENV.delete("RAILS_MASTER_KEY")
end end
test "run" do test "run" do
assert_equal \ assert_equal \
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-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:missing"], @app.run "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 end
test "run with volumes" do test "run with volumes" do
@config[:volumes] = ["/local/path:/container/path" ] @config[:volumes] = ["/local/path:/container/path" ]
assert_equal \ assert_equal \
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-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:missing"], @app.run "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 end
test "run with" do test "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \ assert_equal \
[ :docker, :run, "--rm", "-e", "RAILS_MASTER_KEY=456", "dhh/app:missing", "bin/rails", "db:setup" ], "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_exec("bin/rails", "db:setup") @app.run.join(" ")
end end
test "run without master key" do test "start" do
ENV["RAILS_MASTER_KEY"] = nil assert_equal \
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:skip_master_key] = true }) "docker start app-999",
@app.start.join(" ")
end
assert @app.run.exclude?("RAILS_MASTER_KEY=456") test "stop" do
assert_equal \
"docker ps -q --filter label=service=app | xargs docker stop",
@app.stop.join(" ")
end
test "info" do
assert_equal \
"docker ps --filter label=service=app",
@app.info.join(" ")
end
test "logs" do
assert_equal \
"docker ps -q --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",
@app.logs(since: "5m").join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -n 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",
@app.logs(since: "5m", lines: "100").join(" ")
assert_equal \
"docker ps -q --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'",
@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",
@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\"",
@app.follow_logs(host: "app-1", grep: "Completed")
end
end
test "execute in new container" do
assert_equal \
"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
test "execute in existing container" do
assert_equal \
"docker exec app-999 bin/rails db:setup",
@app.execute_in_existing_container("bin/rails", "db:setup").join(" ")
end
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|,
@app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
test "execute in existing container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_match %r|docker exec -it app-999 bin/rails c|,
@app.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
test "run over ssh" do
assert_equal "ssh -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app" } })
assert_equal "ssh -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "2.2.2.2" } })
assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "app@2.2.2.2" } })
assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" } })
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "current_container_id" do
assert_equal \
"docker ps -q --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",
@app.container_id_for(container_name: "app-999").join(" ")
end
test "current_running_version" do
assert_equal \
"docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1",
@app.current_running_version.join(" ")
end
test "most_recent_version_from_available_images" do
assert_equal \
"docker image ls --format \"{{.Tag}}\" dhh/app | head -n 1",
@app.most_recent_version_from_available_images.join(" ")
end end
end end

View File

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

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

View File

@@ -0,0 +1,55 @@
require "test_helper"
class CommandsHealthcheckTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "run" do
assert_equal \
"docker run -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

23
test/commands/registry_test.rb Executable file
View File

@@ -0,0 +1,23 @@
require "test_helper"
class CommandsRegistryTest < ActiveSupport::TestCase
setup do
@config = { service: "app",
image: "dhh/app",
registry: { "username" => "dhh",
"password" => "secret",
"server" => "hub.docker.com"
},
servers: [ "1.1.1.1" ]
}
@registry = Mrsk::Commands::Registry.new Mrsk::Configuration.new(@config)
end
test "registry login" do
assert_equal [ :docker, :login, "hub.docker.com", "-u", "dhh", "-p", "secret" ], @registry.login
end
test "registry logout" do
assert_equal [:docker, :logout, "hub.docker.com"], @registry.logout
end
end

View File

@@ -1,6 +1,4 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/traefik"
class CommandsTraefikTest < ActiveSupport::TestCase class CommandsTraefikTest < ActiveSupport::TestCase
setup do setup do
@@ -12,8 +10,74 @@ class CommandsTraefikTest < ActiveSupport::TestCase
test "run" do test "run" do
assert_equal \ 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"], "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 new_command.run.join(" ")
end
test "traefik start" do
assert_equal \
"docker container start traefik",
new_command.start.join(" ")
end
test "traefik stop" do
assert_equal \
"docker container stop traefik",
new_command.stop.join(" ")
end
test "traefik info" do
assert_equal \
"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.join(" ")
end
test "traefik logs since 2h" do
assert_equal \
"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).join(" ")
end
test "traefik logs with grep hello!" do
assert_equal \
"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.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.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)
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!')
end end
private private

View File

@@ -1,5 +1,4 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
class ConfigurationAccessoryTest < ActiveSupport::TestCase class ConfigurationAccessoryTest < ActiveSupport::TestCase
setup do setup do
@@ -66,7 +65,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
test "missing host" do test "missing host" do
@deploy[:accessories]["mysql"]["host"] = nil @deploy[:accessories]["mysql"]["host"] = nil
@config = Mrsk::Configuration.new(@deploy) @config = Mrsk::Configuration.new(@deploy)
assert_raises(ArgumentError) do assert_raises(ArgumentError) do
@config.accessory(:mysql).host @config.accessory(:mysql).host
end end

View File

@@ -1,5 +1,4 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
class ConfigurationRoleTest < ActiveSupport::TestCase class ConfigurationRoleTest < ActiveSupport::TestCase
setup do setup do
@@ -63,7 +62,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
end end
test "default traefik label on non-web role" do test "default traefik label on non-web role" do
config = Mrsk::Configuration.new(@deploy_with_roles.tap { |c| config = Mrsk::Configuration.new(@deploy_with_roles.tap { |c|
c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] } c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] }
}) })
@@ -97,7 +96,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
ENV["DB_PASSWORD"] = "secret123" ENV["DB_PASSWORD"] = "secret123"
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=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil
@@ -116,7 +115,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
ENV["DB_PASSWORD"] = "secret123" ENV["DB_PASSWORD"] = "secret123"
assert_equal ["-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args assert_equal ["-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["DB_PASSWORD"] = nil ENV["DB_PASSWORD"] = nil
@@ -133,7 +132,7 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
} }
ENV["REDIS_PASSWORD"] = "secret456" ENV["REDIS_PASSWORD"] = "secret456"
assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure ensure
ENV["REDIS_PASSWORD"] = nil ENV["REDIS_PASSWORD"] = nil

View File

@@ -1,5 +1,4 @@
require "test_helper" require "test_helper"
require "mrsk/configuration"
class ConfigurationTest < ActiveSupport::TestCase class ConfigurationTest < ActiveSupport::TestCase
setup do setup do
@@ -140,17 +139,18 @@ class ConfigurationTest < ActiveSupport::TestCase
test "ssh options" do test "ssh options" do
assert_equal "root", @config.ssh_options[:user] assert_equal "root", @config.ssh_options[:user]
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:ssh_user] = "app" }) config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) })
assert_equal "app", @config.ssh_options[:user] assert_equal "app", @config.ssh_options[:user]
end end
test "master key" do test "ssh options with proxy host" do
assert_equal "456", @config.master_key config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) })
assert_equal "root@1.2.3.4", @config.ssh_options[:proxy].jump_proxies
end end
test "skip master key" do test "ssh options with proxy host and user" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:skip_master_key] = true }) config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) })
assert_nil @config.master_key assert_equal "app@1.2.3.4", @config.ssh_options[:proxy].jump_proxies
end end
test "volume_args" do test "volume_args" do

View File

@@ -2,15 +2,14 @@ require "bundler/setup"
require "active_support/test_case" require "active_support/test_case"
require "active_support/testing/autorun" require "active_support/testing/autorun"
require "debug" require "debug"
require "mocha/minitest" # using #stubs that can alter returns
require "minitest/autorun" # using #stub that take args
require "sshkit" require "sshkit"
require "mrsk"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]
SSHKit.config.backend = SSHKit::Backend::Printer SSHKit.config.backend = SSHKit::Backend::Printer
class ActiveSupport::TestCase class ActiveSupport::TestCase
private
def stdouted
capture(:stdout) { yield }.strip
end
end end