Compare commits

..

112 Commits

Author SHA1 Message Date
David Heinemeier Hansson
be89077917 Bump version for 0.0.3 2023-01-13 10:42:19 +01:00
David Heinemeier Hansson
6bfcc582c8 Singular 2023-01-13 10:30:02 +01:00
David Heinemeier Hansson
fd5172266e More expansive info on builder 2023-01-13 10:28:46 +01:00
David Heinemeier Hansson
e85c8161df Style 2023-01-13 10:28:35 +01:00
David Heinemeier Hansson
a1fc01639e Add build:info to check builder 2023-01-13 10:24:23 +01:00
David Heinemeier Hansson
7e764cbcd9 Explain how to use native builder 2023-01-13 10:18:42 +01:00
David Heinemeier Hansson
f177ee4cfe Make remote builder quack as any other builder 2023-01-13 10:16:28 +01:00
David Heinemeier Hansson
ea9a50ec95 Extract command #combine 2023-01-13 10:00:11 +01:00
David Heinemeier Hansson
6ea06fd04e Log the builder used 2023-01-13 09:49:06 +01:00
David Heinemeier Hansson
6ccb3d2319 Allow for fully native builds too
Skipping multiarch if there's a platform match between dev and prod.
2023-01-13 09:31:47 +01:00
David Heinemeier Hansson
05f1ef5ee8 Registry login actually not necessary 2023-01-12 22:22:22 +01:00
David Heinemeier Hansson
f1a98457b0 Pin platforms 2023-01-12 22:14:05 +01:00
David Heinemeier Hansson
7ae596ef60 Document remote native builds 2023-01-12 21:45:45 +01:00
David Heinemeier Hansson
2257c99189 Add local/remote builder combo for multiarch 2023-01-12 21:35:31 +01:00
David Heinemeier Hansson
5afadb10ca Nicer name for CLI 2023-01-12 18:50:18 +01:00
David Heinemeier Hansson
b3992973d6 Extract builder from app
Building is different from running
2023-01-12 18:16:52 +01:00
David Heinemeier Hansson
08c30a14b9 Use a single builder for MRSK 2023-01-12 18:08:33 +01:00
David Heinemeier Hansson
76d34d2a1c Note quoting issue 2023-01-12 17:42:49 +01:00
David Heinemeier Hansson
184ab18667 Style 2023-01-12 17:38:26 +01:00
David Heinemeier Hansson
87abf06076 Note on exception seen 2023-01-12 17:37:57 +01:00
David Heinemeier Hansson
453570b895 Breakout remove so we can do just containers 2023-01-12 17:37:50 +01:00
David Heinemeier Hansson
f61beb6827 Basic binstub 2023-01-12 17:29:26 +01:00
David Heinemeier Hansson
c481938cdb Reference Traefik docs for more routing rules 2023-01-12 17:16:30 +01:00
David Heinemeier Hansson
7e9b73f86a Add custom labels 2023-01-12 17:15:29 +01:00
David Heinemeier Hansson
1f06b1ff94 Switch to just last 100 log lines for now 2023-01-12 16:00:21 +01:00
David Heinemeier Hansson
d554ae8500 Add back prune 2023-01-12 15:51:01 +01:00
David Heinemeier Hansson
730de486b7 More doc changes 2023-01-12 15:29:56 +01:00
David Heinemeier Hansson
b333c4a05b Simplify presentation of configuration 2023-01-12 15:22:48 +01:00
David Heinemeier Hansson
eec6670dbf Tokens are good too 2023-01-12 15:16:29 +01:00
David Heinemeier Hansson
4aa96d6578 Switch to a Commander base to allow lazy loading config 2023-01-12 14:58:17 +01:00
David Heinemeier Hansson
d3ab10be22 Better require setup 2023-01-12 14:57:34 +01:00
David Heinemeier Hansson
d92318e234 Excess line 2023-01-11 17:58:50 +01:00
David Heinemeier Hansson
e62610069b Correct commadn 2023-01-11 17:46:35 +01:00
David Heinemeier Hansson
a0582c1bdf Explain registry 2023-01-11 17:46:00 +01:00
David Heinemeier Hansson
880ce46c39 Match service name 2023-01-11 17:44:26 +01:00
David Heinemeier Hansson
d049d73547 Realistic looking IP 2023-01-11 17:43:36 +01:00
David Heinemeier Hansson
453fea6c45 Don't rely on ERB interpolation that might fail
Error message isn't good
2023-01-11 17:43:28 +01:00
David Heinemeier Hansson
2694cf5d5f Make init more resilient and communicative 2023-01-11 17:43:07 +01:00
David Heinemeier Hansson
5324fbe3d0 Give feedback on what happened 2023-01-11 17:35:53 +01:00
David Heinemeier Hansson
5e214cde3c Explain where to set this 2023-01-11 17:35:46 +01:00
David Heinemeier Hansson
f61f41ad73 Document app console 2023-01-11 17:28:18 +01:00
David Heinemeier Hansson
d9cdbb87f9 Heads up that this could take a while 2023-01-11 17:26:49 +01:00
David Heinemeier Hansson
543af475d5 Create missing buildx builder if missing automatically 2023-01-11 17:24:32 +01:00
David Heinemeier Hansson
1bb9fe9095 Reuse existing exec command 2023-01-11 17:11:57 +01:00
David Heinemeier Hansson
c6fd4399f1 Hint at which version to start 2023-01-11 17:07:34 +01:00
David Heinemeier Hansson
a4a9f619ad Protect against missing envs 2023-01-11 17:07:22 +01:00
David Heinemeier Hansson
4392bf0ee9 Allow you to turn full verbosity on easily 2023-01-11 17:05:20 +01:00
David Heinemeier Hansson
3b3ab48120 Set a different verbosity level for the duration of the yield 2023-01-11 17:01:19 +01:00
David Heinemeier Hansson
606550d46b Reveal what was pruned 2023-01-11 17:01:12 +01:00
David Heinemeier Hansson
e1b327915f Use error logger instead 2023-01-11 17:01:03 +01:00
David Heinemeier Hansson
9d3871d667 Split out proper Prune command 2023-01-11 16:48:10 +01:00
David Heinemeier Hansson
7d83be2d18 Readability 2023-01-11 16:26:26 +01:00
David Heinemeier Hansson
3e2c48782c Explaining consts 2023-01-11 13:31:25 +01:00
David Heinemeier Hansson
bcdeeff94f Start remote Rails console on primary host 2023-01-10 20:45:15 +01:00
David Heinemeier Hansson
c5249b4a9e Host yield not needed 2023-01-10 20:44:54 +01:00
David Heinemeier Hansson
57e49bb26c Bump version for 0.0.2 2023-01-10 19:16:34 +01:00
David Heinemeier Hansson
1609b43ef8 Temporary fix for #2 2023-01-10 19:15:40 +01:00
David Heinemeier Hansson
f9010c1b75 Only run Traefik on web role 2023-01-10 19:04:35 +01:00
David Heinemeier Hansson
73b7c691d6 Fix references 2023-01-10 18:56:30 +01:00
David Heinemeier Hansson
3473ec7a86 Explain running job servers separately 2023-01-10 17:31:36 +01:00
David Heinemeier Hansson
e8beb362d0 Add role concern with specialized cmds for job running 2023-01-10 17:27:56 +01:00
David Heinemeier Hansson
1cee87d440 Latest bundler 2023-01-10 15:02:25 +01:00
David Heinemeier Hansson
c2e09b9b2f Added debug 2023-01-10 14:24:25 +01:00
David Heinemeier Hansson
78a5d08d3f Switch to host naming
Servers concept will encompass custom cmd and labels. Host is just the IP address.
2023-01-10 14:15:16 +01:00
David Heinemeier Hansson
5ca6f32ee7 Use debug gem 2023-01-10 13:17:28 +01:00
David Heinemeier Hansson
6b098a1e2e Ruby 3.2.0 compatibility 2023-01-10 13:17:18 +01:00
David Heinemeier Hansson
ff5ccac8fe Cleanup 2023-01-09 20:44:54 +01:00
David Heinemeier Hansson
b4edf8eef9 Ignore nil command bits
They might come from conditional options
2023-01-09 18:08:34 +01:00
David Heinemeier Hansson
fe52ce6547 Add command execution 2023-01-09 14:36:33 +01:00
David Heinemeier Hansson
9641ce0edd Update README.md 2023-01-08 18:38:55 +01:00
David Heinemeier Hansson
1dab9c1fb5 More documentation 2023-01-08 16:50:06 +01:00
David Heinemeier Hansson
10d973200d Add command to list containers (to ease rollback) 2023-01-08 16:45:41 +01:00
David Heinemeier Hansson
fdfdff65e9 Explain command map overwrite 2023-01-08 16:33:09 +01:00
David Heinemeier Hansson
94d61f3d9a Proper param array separation 2023-01-08 16:32:31 +01:00
David Heinemeier Hansson
483f686efc Test config labels 2023-01-08 16:29:59 +01:00
David Heinemeier Hansson
51adbc032e Test app#run 2023-01-08 16:29:51 +01:00
David Heinemeier Hansson
dcb3e4d491 Switch envs and labels to param array 2023-01-08 16:29:44 +01:00
David Heinemeier Hansson
55445ae110 Style 2023-01-08 16:22:50 +01:00
David Heinemeier Hansson
998525c93d Switch to cmd array so we can redact 2023-01-08 16:20:06 +01:00
David Heinemeier Hansson
4ec04f8959 Language 2023-01-08 15:13:51 +01:00
David Heinemeier Hansson
3ddf2b9c41 Distinguish run from start 2023-01-08 15:13:45 +01:00
David Heinemeier Hansson
d4210b66d0 Language 2023-01-08 15:13:28 +01:00
David Heinemeier Hansson
7f37abac59 If already started, just carry on 2023-01-08 15:13:24 +01:00
David Heinemeier Hansson
399d32d7d0 Known VERSION means we've already pushed 2023-01-08 15:13:14 +01:00
David Heinemeier Hansson
8d16271150 Make run resilient to same version having already been run 2023-01-08 15:13:03 +01:00
David Heinemeier Hansson
e1724e0cd9 Clarify output 2023-01-08 14:55:51 +01:00
David Heinemeier Hansson
43eac9d414 Use DRY extraction 2023-01-08 14:55:14 +01:00
David Heinemeier Hansson
ffb532a50d Add remove tasks to clean up 2023-01-08 14:55:06 +01:00
David Heinemeier Hansson
23c2cb898c Explain need to match with Dockerfile LABEL 2023-01-08 14:38:03 +01:00
David Heinemeier Hansson
14867a2f61 Allow logging out of registry 2023-01-08 14:18:00 +01:00
David Heinemeier Hansson
4b46449fdf Split out repository to be used alone 2023-01-08 14:07:29 +01:00
David Heinemeier Hansson
87ca059f32 Fix dangling parenthesis 2023-01-08 14:07:08 +01:00
David Heinemeier Hansson
1fcc2d3cfd Remember to use Shellwords later 2023-01-08 13:39:38 +01:00
David Heinemeier Hansson
d43ceb975f Create config stub with mrsk:init 2023-01-08 13:39:29 +01:00
David Heinemeier Hansson
4f06b5f99b Clarify that one server needn't an LB 2023-01-08 12:14:46 +01:00
David Heinemeier Hansson
21df2aefe5 Prune containers first to release images 2023-01-08 12:13:19 +01:00
David Heinemeier Hansson
5979f1d43e Prune by default after deploy 2023-01-08 12:11:44 +01:00
David Heinemeier Hansson
9e7ce59b85 Use a shared prune 2023-01-08 12:08:28 +01:00
David Heinemeier Hansson
6e853786eb Prepare for auto-pruning 2023-01-08 11:54:43 +01:00
David Heinemeier Hansson
e378e9a6dd Not used 2023-01-08 11:54:32 +01:00
David Heinemeier Hansson
6c3a4b1792 Explain rollback 2023-01-08 11:47:04 +01:00
David Heinemeier Hansson
73019bedfb Keep containers around for quick rollback + restarting
Now need to deal with pruning.
2023-01-08 11:45:48 +01:00
David Heinemeier Hansson
e8fc046537 Update README.md 2023-01-08 11:33:09 +01:00
David Heinemeier Hansson
a45a40b996 Done 2023-01-08 11:29:55 +01:00
David Heinemeier Hansson
3cad095e2b Add ERB eval so we can use credentials 2023-01-08 11:11:57 +01:00
David Heinemeier Hansson
cc3619173d Split out push/pull and aggregate in deliver 2023-01-08 10:07:32 +01:00
David Heinemeier Hansson
ddb4d549f2 Need setup 2023-01-08 10:07:13 +01:00
David Heinemeier Hansson
7f220ea987 Bootstrap entirely clean new server 2023-01-08 10:07:08 +01:00
David Heinemeier Hansson
4cbc4aa9b7 Update README.md 2023-01-08 09:37:12 +01:00
David Heinemeier Hansson
9c6cd33dec Ensure we're logged in 2023-01-08 09:35:55 +01:00
David Heinemeier Hansson
ef87cd5634 Explain registry configuration 2023-01-08 09:35:45 +01:00
David Heinemeier Hansson
9d9a9c4116 Only need absolute_image 2023-01-07 22:02:28 +01:00
35 changed files with 1203 additions and 141 deletions

View File

@@ -1,9 +1,6 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Specify your gem's dependencies in importmap-rails.gemspec.
gemspec
group :test do
gem "byebug"
end
gem "debug"

View File

@@ -1,7 +1,7 @@
PATH
remote: .
specs:
mrsk (0.0.1)
mrsk (0.0.3)
railties (>= 7.0.0)
sshkit (~> 1.21)
@@ -27,12 +27,17 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
builder (3.2.4)
byebug (11.1.3)
concurrent-ruby (1.1.10)
crass (1.0.6)
debug (1.7.1)
irb (>= 1.5.0)
reline (>= 0.3.1)
erubi (1.12.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
io-console (0.6.0)
irb (1.6.2)
reline (>= 0.3.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@@ -41,7 +46,11 @@ GEM
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1)
nokogiri (1.13.10-arm64-darwin)
nokogiri (1.14.0.rc1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-linux)
racc (~> 1.4)
racc (1.6.2)
rack (2.2.5)
@@ -60,6 +69,8 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rake (13.0.6)
reline (0.3.2)
io-console (~> 0.5)
sshkit (1.21.3)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
@@ -77,8 +88,8 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
byebug
debug
mrsk!
BUNDLED WITH
2.2.33
2.4.3

262
README.md
View File

@@ -1,55 +1,267 @@
# MRSK
MRSK lets you do zero-downtime deploys of Rails apps packed as containers to any host running Docker. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is wound down. It works across multiple hosts at the same time, using SSHKit to execute commands.
MRSK ships zero-downtime deploys of Rails apps packed as containers to any host. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is wound down. It works seamlessly across multiple hosts, using SSHKit to execute commands.
## Installation
Create a configuration file for MRSK in `config/deploy.yml` that looks like this:
Add the gem with `bundle add mrsk`, then run `rake mrsk:init`, and then edit the new file in `config/deploy.yml`. It could look as simple as this:
```yaml
service: my-app
image: name/my-app
service: hey
image: 37s/hey
servers:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
env:
DATABASE_URL: mysql2://username@localhost/database_name/
REDIS_URL: redis://host:6379/1
- 192.168.0.1
- 192.168.0.2
registry:
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>
```
Then first login to the Docker Hub registry on the servers:
Then ensure your encrypted credentials have the registry username + password by editing them with `rails credentials:edit`:
```
rake mrsk:registry:login DOCKER_USER=name DOCKER_PASSWORD=pw
registry:
username: real-user-name
password: real-registry-password-or-token
```
Now you're ready to deploy a multi-arch image (FIXME: currently you need to manually run `docker buildx create --use` once first):
Now you're ready to deploy a multi-arch image to the servers:
```
rake mrsk:deploy
./bin/mrsk deploy
```
This will:
1. Build the image using the standard Dockerfile in the root of the application.
2. Push the image to the registry.
3. Pull the image on all the servers.
4. Ensure Traefik is running and accepting traffic on port 80.
5. Stop any containers running a previous versions of the app.
6. Start a new container with the version of the app that matches the current git version hash.
1. Log into the registry both locally and remotely
2. Build the image using the standard Dockerfile in the root of the application.
3. Push the image to the registry.
4. Pull the image from the registry on the servers.
5. Ensure Traefik is running and accepting traffic on port 80.
6. Stop any containers running a previous versions of the app.
7. Start a new container with the version of the app that matches the current git version hash.
8. 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, and you're ready to put them behind a load balancer to serve live traffic.
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.
## Configuration
### Using another registry than Docker Hub
The default registry for Docker is Docker Hub. If you'd like to use a different one, just configure the server, like so:
```yaml
registry:
server: registry.digitalocean.com
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>
```
### Using a different SSH user than root
The default SSH user is root, but you can change it using `ssh_user`:
```yaml
ssh_user: app
```
### Adding custom env variables
You can inject custom env variables into the app containers using `env`:
```yaml
env:
DATABASE_URL: mysql2://db1/hey_production/
REDIS_URL: redis://redis1:6379/1
```
### Splitting servers into different roles
If your application uses separate hosts for running jobs or other roles beyond the default web running, you can specify these hosts and their custom entrypoint command like so:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
```
Traefik will only be installed and run on the servers in the `web` role (and on all servers if no roles are defined).
### Adding custom container labels
You can specialize the default Traefik rules by setting custom labels on the containers that are being started:
```
labels:
traefik.http.routers.hey.rule: '''Host(`app.hey.com`)'''
```
(Note: The extra quotes are needed to ensure the rule is passed in correctly!)
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.
The labels can even be applied on a per-role basis:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
labels:
my-custom-label: "50"
```
### Configuring remote builder for native multi-arch
If you're developing on ARM64 (like Apple Silicon), but you want to deploy on AMD64 (x86 64-bit), you have to use multi-archecture images. By default, MRSK will setup a local buildx configuration that allows for this through QEMU emulation. This can be slow, especially on the first build.
If you want to speed up this process by using a remote AMD64 host to natively build the AMD64 part of the image, while natively building the ARM64 part locally, you can do so using builder options like follows:
```yaml
builder:
local:
arch: arm64
host: unix:///Users/dhh/.docker/run/docker.sock
remote:
arch: amd64
host: ssh://root@192.168.0.1
```
Note: You must have Docker running on the remote host being used as a builder.
With that configuration in place, you can setup the local/remote configuration using `./bin/mrsk build:remote:create`. If you wish to remove the contexts and buildx instances again, you can run `./bin/mrsk build:remote:remove`. If you had already built using the standard emulation setup, run `./bin/mrsk build:remove` before doing `./bin/mrsk build:remote:create`.
### Configuring native builder when multi-arch isn't needed
If you're developing on the same architecture as the one you're deploying on, you can speed up the build a lot by forgoing a multi-arch image. This can be done by configuring the builder like so:
```yaml
builder:
multiarch: false
```
## Commands
### Remote execution
If you need to execute commands inside the Rails containers, you can use `./bin/mrsk app:exec`, `./bin/mrsk app:exec:once`, `./bin/mrsk app:exec:rails`, and `./bin/mrsk app:exec:once:rails`. Examples:
```bash
# Runs command on all servers
./bin/mrsk app:exec CMD='ruby -v'
App Host: xxx.xxx.xxx.xxx
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
App Host: xxx.xxx.xxx.xxx
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
# Runs command on first server
./bin/mrsk app:exec:once CMD='cat .ruby-version'
3.1.3
# Runs Rails command on all servers
./bin/mrsk app:exec:rails CMD=about
App Host: xxx.xxx.xxx.xxx
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
App Host: xxx.xxx.xxx.xxx
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
# Runs Rails command on first server
./bin/mrsk app:exec:once:rails CMD='db:version'
database: storage/production.sqlite3
Current version: 20221231233303
```
### Running a Rails console on the primary host
If you need to interact with the production console for the app, you can use `./bin/mrsk app:console`, which will start a Rails console session on the primary host. Be mindful that this is a live wire! Any changes made to the production database will take effect immeditately.
### Inspecting
You can see the state of your servers by running `./bin/mrsk info`. It'll show something like this:
```
Traefik Host: xxx.xxx.xxx.xxx
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6195b2a28c81 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
Traefik Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
de14a335d152 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
App Host: 164.90.145.60
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db3 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
App Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f55 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
```
You can also see just info for app containers with `./bin/mrsk app:info` or just for Traefik with `./bin/mrsk traefik:info`.
### Rollback
If you've discovered a bad deploy, you can quickly rollback by reactivating the old, paused container image. You can see what old containers are available for rollback by running `./bin/mrsk app:containers`. It'll give you a presentation similar to `./bin/mrsk app:info`, but include all the old containers as well. Showing something like this:
```
App Host: 164.92.105.119
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f51 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
539f26b28369 registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
App Host: 164.90.145.60
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db4 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
6f170d1172ae registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
```
From the example above, we can see that `e5d9d7c2b898289dfbc5f7f1334140d984eedae4` was the last version, so it's available as a rollback target. We can perform this rollback by running `./bin/mrsk rollback VERSION=e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. That'll stop `6ef8a6a84c525b123c5245345a8483f86d05a123` and then start `e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. Because the old container is still available, this is very quick. Nothing to download from the registry.
Note that by default old containers are pruned after 3 days when you run `./bin/mrsk deploy`.
### Removing
If you wish to remove the entire application, including Traefik, containers, images, and registry session, you can run `./bin/mrsk remove`. This will leave the servers clean.
## Stage of development
This is alpha software. Lots of stuff is missing. Here are some of the areas we seek to improve:
- Use of other registries than Docker Hub
- Adapterize commands to work with Podman and other container runners
- Better flow for secrets and ENV
- Possibly switching to a bin/mrsk command rather than raw rake
- Integrate wirmth cloud CI pipelines
- Integrate with cloud CI pipelines
## License
Mrsk is released under the [MIT License](https://opensource.org/licenses/MIT).
MRSK is released under the [MIT License](https://opensource.org/licenses/MIT).

View File

@@ -3,6 +3,4 @@ end
require "mrsk/version"
require "mrsk/engine"
require "mrsk/configuration"
require "mrsk/commands"
require "mrsk/commander"

56
lib/mrsk/commander.rb Normal file
View File

@@ -0,0 +1,56 @@
require "mrsk/configuration"
require "mrsk/commands/app"
require "mrsk/commands/builder"
require "mrsk/commands/prune"
require "mrsk/commands/traefik"
require "mrsk/commands/registry"
class Mrsk::Commander
attr_reader :config_file, :config, :verbose
def initialize(config_file:, verbose: false)
@config_file, @verbose = config_file, verbose
end
def config
@config ||= Mrsk::Configuration.load_file(config_file).tap { |config| setup_with(config) }
end
def app
@app ||= Mrsk::Commands::App.new(config)
end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def verbosity(level)
old_level = SSHKit.config.output_verbosity
SSHKit.config.output_verbosity = level
yield
ensure
SSHKit.config.output_verbosity = old_level
end
private
# Lazy setup of SSHKit
def setup_with(config)
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options }
SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.output_verbosity = :debug if verbose
end
end

View File

@@ -1,13 +1,2 @@
module Mrsk::Commands
class Base
attr_accessor :config
def initialize(config)
@config = config
end
end
end
require "mrsk/commands/app"
require "mrsk/commands/traefik"
require "mrsk/commands/registry"

View File

@@ -1,22 +1,63 @@
class Mrsk::Commands::App < Mrsk::Commands::Base
def push
# TODO: Run 'docker buildx create --use' when needed
"docker buildx build --push --platform=linux/amd64,linux/arm64 -t #{config.absolute_image} ."
end
require "mrsk/commands/base"
def pull
"docker pull #{config.absolute_image}"
class Mrsk::Commands::App < Mrsk::Commands::Base
def run(role: :web)
role = config.role(role)
docker :run,
"-d",
"--restart unless-stopped",
"--name", config.service_with_version,
"-e", redact("RAILS_MASTER_KEY=#{config.master_key}"),
*config.env_args,
*role.label_args,
config.absolute_image,
role.cmd
end
def start
"docker run -d --rm --name #{config.service_with_version} #{config.envs} #{config.labels} #{config.absolute_image}"
docker :start, config.service_with_version
end
def stop
"docker ps -q --filter label=service=#{config.service} | xargs docker stop"
[ "docker ps -q #{service_filter.join(" ")} | xargs docker stop" ]
end
def info
"docker ps --filter label=service=#{config.service}"
docker :ps, *service_filter
end
def logs
[ "docker ps -q #{service_filter.join(" ")} | xargs docker logs -n 100 -t" ]
end
def exec(*command, interactive: false)
docker :exec,
("-it" if interactive),
"-e", redact("RAILS_MASTER_KEY=#{config.master_key}"),
*config.env_args,
config.service_with_version,
*command
end
def console
"ssh -t #{config.ssh_user}@#{config.primary_host} '#{exec("bin/rails", "c", interactive: true).join(" ")}'"
end
def list_containers
docker :container, :ls, "-a", *service_filter
end
def remove_containers
docker :container, :prune, "-f", *service_filter
end
def remove_images
docker :image, :prune, "-a", "-f", *service_filter
end
private
def service_filter
[ "--filter", "label=service=#{config.service}" ]
end
end

27
lib/mrsk/commands/base.rb Normal file
View File

@@ -0,0 +1,27 @@
require "sshkit"
module Mrsk::Commands
class Base
attr_accessor :config
def initialize(config)
@config = config
end
private
def combine(*commands)
commands
.collect { |command| command + [ "&&" ] }.flatten # Join commands with &&
.tap { |commands| commands.pop } # Remove trailing &&
end
def docker(*args)
args.compact.unshift :docker
end
# Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes
def redact(arg) # Used in execute_command to hide redact() args a user passes in
arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc
end
end
end

View File

@@ -0,0 +1,39 @@
require "mrsk/commands/base"
class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :pull, :info, to: :target
delegate :native?, :multiarch?, :remote?, to: :name
def name
target.class.to_s.demodulize.downcase.inquiry
end
def target
case
when config.builder.nil?
multiarch
when config.builder["multiarch"] == false
native
when config.builder["local"] && config.builder["local"]
multiarch_remote
else
raise ArgumentError, "Builder configuration incorrect: #{config.builder.inspect}"
end
end
def native
@native ||= Mrsk::Commands::Builder::Native.new(config)
end
def multiarch
@multiarch ||= Mrsk::Commands::Builder::Multiarch.new(config)
end
def multiarch_remote
@multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config)
end
end
require "mrsk/commands/builder/native"
require "mrsk/commands/builder/multiarch"
require "mrsk/commands/builder/multiarch/remote"

View File

@@ -0,0 +1,25 @@
require "mrsk/commands/base"
class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Base
def create
docker :buildx, :create, "--use", "--name", "mrsk"
end
def remove
docker :buildx, :rm, "mrsk"
end
def push
docker :buildx, :build, "--push", "--platform linux/amd64,linux/arm64", "-t", config.absolute_image, "."
end
def pull
docker :pull, config.absolute_image
end
def info
combine \
docker(:context, :ls),
docker(:buildx, :ls)
end
end

View File

@@ -0,0 +1,53 @@
require "mrsk/commands/builder/multiarch"
class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Multiarch
def create
combine \
create_contexts,
create_local_buildx,
append_remote_buildx
end
def remove
combine \
remove_contexts,
super
end
private
def create_local_buildx
docker :buildx, :create, "--use", "--name", "mrsk", "mrsk-#{local["arch"]}", "--platform", "linux/#{local["arch"]}"
end
def append_remote_buildx
docker :buildx, :create, "--append", "--name", "mrsk", "mrsk-#{remote["arch"]}", "--platform", "linux/#{remote["arch"]}"
end
def create_contexts
combine \
create_context(local["arch"], local["host"]),
create_context(remote["arch"], remote["host"])
end
def create_context(arch, host)
docker :context, :create, "mrsk-#{arch}", "--description", "'MRSK #{arch} Native Host'", "--docker", "'host=#{host}'"
end
def remove_contexts
combine \
remove_context(local["arch"]),
remove_context(remote["arch"])
end
def remove_context(arch)
docker :context, :rm, "mrsk-#{arch}"
end
def local
config.builder["local"]
end
def remote
config.builder["remote"]
end
end

View File

@@ -0,0 +1,25 @@
require "mrsk/commands/base"
class Mrsk::Commands::Builder::Native < Mrsk::Commands::Base
def create
# No-op on native
end
def remove
# No-op on native
end
def push
combine \
docker(:build, "-t", config.absolute_image, "."),
docker(:push, config.absolute_image)
end
def pull
docker :pull, config.absolute_image
end
def info
# No-op on native
end
end

View File

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

View File

@@ -1,5 +1,13 @@
require "mrsk/commands/base"
class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config
def login
"docker login #{config.registry["server"]} -u #{config.registry["username"]} -p #{config.registry["password"]}"
docker :login, registry["server"], "-u", redact(registry["username"]), "-p", redact(registry["password"])
end
def logout
docker :logout, registry["server"]
end
end

View File

@@ -1,17 +1,37 @@
require "mrsk/commands/base"
class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
"-d",
"--restart unless-stopped",
"-p 80:80",
"-v /var/run/docker.sock:/var/run/docker.sock",
"traefik",
"--providers.docker"
end
def start
"docker run --name traefik " +
"--rm -d " +
"-p 80:80 " +
"-v /var/run/docker.sock:/var/run/docker.sock " +
"traefik --providers.docker"
docker :container, :start, "traefik"
end
def stop
"docker container stop traefik"
docker :container, :stop, "traefik"
end
def info
"docker ps --filter name=traefik"
docker :ps, "--filter", "name=traefik"
end
def logs
docker :logs, "traefik", "-n", "100", "-t"
end
def remove_container
docker :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end
end

View File

@@ -1,60 +1,100 @@
require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry"
require "erb"
class Mrsk::Configuration
delegate :service, :image, :env, :registry, :ssh_user, to: :config, allow_nil: true
delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :config, allow_nil: true
def self.load_file(file)
class << self
def load_file(file)
if file.exist?
new YAML.load_file(file).symbolize_keys
new YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
else
raise "Configuration file not found in #{file}"
end
end
def initialize(config)
@config = ActiveSupport::InheritableOptions.new(config)
ensure_required_keys_present
def argumentize(argument, attributes)
attributes.flat_map { |k, v| [ argument, "#{k}=#{v}" ] }
end
end
def servers
ENV["SERVERS"] || config.servers
def initialize(config, validate: true)
@config = ActiveSupport::InheritableOptions.new(config)
ensure_required_keys_present if validate
end
def roles
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
end
def role(name)
roles.detect { |r| r.name == name.to_s }
end
def hosts
hosts =
case
when ENV["HOSTS"]
ENV["HOSTS"].split(",")
when ENV["ROLES"]
role_names = ENV["ROLES"].split(",")
roles.select { |r| role_names.include?(r.name) }.flat_map(&:hosts)
else
roles.flat_map(&:hosts)
end
if hosts.any?
hosts
else
raise ArgumentError, "No hosts found"
end
end
def primary_host
role(:web).hosts.first
end
def version
@version ||= ENV["VERSION"] || `git rev-parse HEAD`.strip
end
def absolute_image
[ config.registry["server"], image_with_version ].compact.join("/")
def repository
[ config.registry["server"], image ].compact.join("/")
end
def image_with_version
"#{image}:#{version}"
def absolute_image
"#{repository}:#{version}"
end
def service_with_version
"#{service}-#{version}"
end
def envs
parameterize "-e", \
{ "RAILS_MASTER_KEY" => master_key }.merge(env || {})
def env_args
if config.env.present?
self.class.argumentize "-e", config.env
else
[]
end
end
def labels
parameterize "--label", \
"service" => service,
"traefik.http.routers.#{service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{service}.retry.attempts" => "3",
"traefik.http.middlewares.#{service}.retry.initialinterval" => "500ms"
def ssh_user
config.ssh_user || "root"
end
def ssh_options
{ user: config.ssh_user || "root", auth_methods: [ "publickey" ] }
{ user: ssh_user, auth_methods: [ "publickey" ] }
end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
end
private
attr_accessor :config
@@ -68,11 +108,9 @@ class Mrsk::Configuration
end
end
def parameterize(param, hash)
hash.collect { |k, v| "#{param} #{k}=#{v}" }.join(" ")
def role_names
config.servers.is_a?(Array) ? [ "web" ] : config.servers.keys.sort
end
end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
end
end
require "mrsk/configuration/role"

View File

@@ -0,0 +1,70 @@
class Mrsk::Configuration::Role
delegate :argumentize, to: Mrsk::Configuration
attr_accessor :name
def initialize(name, config:)
@name, @config = name.inquiry, config
end
def hosts
@hosts ||= extract_hosts_from_config
end
def labels
if name.web?
default_labels.merge(traefik_labels).merge(custom_labels)
else
default_labels.merge(custom_labels)
end
end
def label_args
argumentize "--label", labels
end
def cmd
specializations["cmd"]
end
private
attr_accessor :config
def extract_hosts_from_config
if config.servers.is_a?(Array)
config.servers
else
servers = config.servers[name]
servers.is_a?(Array) ? servers : servers["hosts"]
end
end
def default_labels
{ "service" => config.service, "role" => name }
end
def traefik_labels
{
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms"
}
end
def custom_labels
Hash.new.tap do |labels|
labels.merge!(config.labels) if config.labels.present?
labels.merge!(specializations["labels"]) if specializations["labels"].present?
end
end
def specializations
if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
{ }
else
config.servers[name].without("hosts")
end
end
end

View File

@@ -1,3 +1,3 @@
module Mrsk
VERSION = "0.0.1"
VERSION = "0.0.3"
end

View File

@@ -1,31 +1,97 @@
require_relative "setup"
app = Mrsk::Commands::App.new(MRSK_CONFIG)
namespace :mrsk do
namespace :app do
desc "Build and push app image to servers"
task :push do
run_locally { execute app.push }
on(MRSK_CONFIG.servers) { execute app.pull }
desc "Run app on servers (or start them if they've already been run)"
task :run do
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
begin
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Container with same version already deployed on #{host}, starting that instead"
execute *MRSK.app.start, host: host
else
raise
end
end
end
end
end
desc "Start app on servers"
desc "Start existing app on servers (use VERSION=<git-hash> to designate which version)"
task :start do
on(MRSK_CONFIG.servers) { execute app.start }
on(MRSK.config.hosts) { execute *MRSK.app.start, raise_on_non_zero_exit: false }
end
desc "Stop app on servers"
task :stop do
on(MRSK_CONFIG.servers) { execute app.stop, raise_on_non_zero_exit: false }
on(MRSK.config.hosts) { execute *MRSK.app.stop, raise_on_non_zero_exit: false }
end
desc "Restart app on servers"
desc "Start app on servers (use VERSION=<git-hash> to designate which version)"
task restart: %i[ stop start ]
desc "Display information about app containers"
task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(app.info) + "\n\n" }
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.info) + "\n\n" }
end
desc "Execute a custom task on servers passed in as CMD='bin/rake some:task'"
task :exec do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec(ENV["CMD"])) + "\n\n" }
end
desc "Start Rails Console on primary host"
task :console do
puts "Launching Rails console on #{MRSK.config.primary_host}..."
exec app.console
end
namespace :exec do
desc "Execute Rails command on servers, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec("bin/rails", ENV["CMD"])) + "\n\n" }
end
desc "Execute a custom task on the first defined server"
task :once do
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec(ENV["CMD"])) }
end
namespace :once do
desc "Execute Rails command on the first defined server, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec("bin/rails", ENV["CMD"])) }
end
end
end
desc "List all the app containers currently on servers"
task :containers do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.list_containers) + "\n\n" }
end
desc "Show last 100 log lines from app on servers"
task :logs do
# FIXME: Catch when app containers aren't running
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.logs) + "\n\n" }
end
desc "Remove app containers and images from servers"
task remove: %w[ remove:containers remove:images ]
namespace :remove do
desc "Remove app containers from servers"
task :containers do
on(MRSK.config.hosts) { execute *MRSK.app.remove_containers }
end
desc "Remove app images from servers"
task :images do
on(MRSK.config.hosts) { execute *MRSK.app.remove_images }
end
end
end
end

52
lib/tasks/mrsk/build.rake Normal file
View File

@@ -0,0 +1,52 @@
require_relative "setup"
namespace :mrsk do
namespace :build do
desc "Deliver a newly built app image to servers"
task deliver: %i[ push pull ]
desc "Build locally and push app image to registry"
task :push do
run_locally do
begin
debug "Using builder: #{MRSK.builder.name}"
info "Building image may take a while (run with VERBOSE=1 for progress logging)"
execute *MRSK.builder.push
rescue SSHKit::Command::Failed => e
error "Missing compatible builder, so creating a new one first"
execute *MRSK.builder.create
execute *MRSK.builder.push
end
end unless ENV["VERSION"]
end
desc "Pull app image from the registry onto servers"
task :pull do
on(MRSK.config.hosts) { execute *MRSK.builder.pull }
end
desc "Create a local build setup"
task :create do
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.create
end
end
desc "Remove local build setup"
task :remove do
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end
desc "Show the name of the configured builder"
task :info do
run_locally do
puts "Builder: #{MRSK.builder.name} (#{MRSK.builder.target.class.name})"
puts capture(*MRSK.builder.info)
end
end
end
end

View File

@@ -1,12 +1,37 @@
require_relative "setup"
namespace :mrsk do
desc "Deploy app for the first time to a fresh server"
task fresh: %w[ server:bootstrap registry:login build:deliver traefik:run app:stop app:run ]
desc "Push the latest version of the app, ensure Traefik is running, then restart app"
task deploy: [ "app:push", "traefik:start", "app:restart" ]
task deploy: %w[ registry:login build:deliver traefik:run app:stop app:run prune ]
desc "Rollback to VERSION=x that was already run as a container on servers"
task rollback: %w[ app:restart ]
desc "Display information about Traefik and app containers"
task info: [ "traefik:info", "app:info" ]
task info: %w[ traefik:info app:info ]
desc "Create config stub"
desc "Create config stub in config/deploy.yml"
task :init do
Rails.root.join("config/deploy.yml")
require "fileutils"
if (deploy_file = Rails.root.join("config/deploy.yml")).exist?
puts "Config file already exists in config/deploy.yml (remove first to create a new one)"
else
FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file
puts "Created configuration file in config/deploy.yml"
end
if (binstub = Rails.root.join("bin/mrsk")).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else
FileUtils.cp_r Pathname.new(File.expand_path("templates/mrsk", __dir__)), binstub
puts "Created binstub file in bin/mrsk"
end
end
desc "Remove Traefik, app, and registry session from servers"
task remove: %w[ traefik:remove app:remove registry:logout ]
end

18
lib/tasks/mrsk/prune.rake Normal file
View File

@@ -0,0 +1,18 @@
require_relative "setup"
namespace :mrsk do
desc "Prune unused images and stopped containers"
task prune: %w[ prune:containers prune:images ]
namespace :prune do
desc "Prune unused images older than 30 days"
task :images do
on(MRSK.config.hosts) { MRSK.verbosity(:debug) { execute *MRSK.prune.images } }
end
desc "Prune stopped containers for the service older than 3 days"
task :containers do
on(MRSK.config.hosts) { MRSK.verbosity(:debug) { execute *MRSK.prune.containers } }
end
end
end

View File

@@ -1,13 +1,16 @@
require_relative "setup"
registry = Mrsk::Commands::Registry.new(MRSK_CONFIG)
namespace :mrsk do
namespace :registry do
desc "Login to the registry locally and remotely"
task :login do
run_locally { execute registry.login }
on(MRSK_CONFIG.servers) { execute registry.login }
run_locally { execute *MRSK.registry.login }
on(MRSK.config.hosts) { execute *MRSK.registry.login }
end
desc "Logout of the registry remotely"
task :logout do
on(MRSK.config.hosts) { execute *MRSK.registry.logout }
end
end
end

View File

@@ -0,0 +1,11 @@
require_relative "setup"
namespace :mrsk do
namespace :server do
desc "Setup Docker on the remote servers"
task :bootstrap do
# FIXME: Detect when apt-get is not available and use the appropriate alternative
on(MRSK.config.hosts) { execute "apt-get install docker.io -y" }
end
end
end

View File

@@ -3,6 +3,4 @@ require "sshkit/dsl"
include SSHKit::DSL
MRSK_CONFIG = Mrsk::Configuration.load_file(Rails.root.join("config/deploy.yml"))
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = MRSK_CONFIG.ssh_options }
MRSK = Mrsk::Commander.new config_file: Rails.root.join("config/deploy.yml"), verbose: ENV["VERBOSE"]

View File

@@ -0,0 +1,24 @@
# Name of your application will be used for uniquely configuring Traefik and app containers.
# Your Dockerfile should set LABEL service=the-same-value to ensure image pruning works.
service: my-app
# Name of the container image
image: user/my-app
# All the servers targeted for deploy. You can reference a single server for a command by using SERVERS=192.168.0.1
servers:
- 192.168.0.1
# The following envs are made available to the container when started
env:
# Remember never to put passwords or tokens directly into this file, use encrypted credentials
# REDIS_URL: redis://x/y
# Where your images will be hosted
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
# Set credentials with bin/rails credentials:edit
username: my-user
password: my-password-should-go-in-credentials

8
lib/tasks/mrsk/templates/mrsk Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
if [ "${*}" == "" ]; then
# Improve so list matches
exec bin/rake -T mrsk
else
exec bin/rake "mrsk:$@"
fi

View File

@@ -1,25 +1,41 @@
require_relative "setup"
traefik = Mrsk::Commands::Traefik.new(MRSK_CONFIG)
namespace :mrsk do
namespace :traefik do
desc "Start Traefik"
desc "Run Traefik on servers"
task :run do
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false }
end
desc "Start existing Traefik on servers"
task :start do
on(MRSK_CONFIG.servers) { execute traefik.start, raise_on_non_zero_exit: false }
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.start, raise_on_non_zero_exit: false }
end
desc "Stop Traefik"
desc "Stop Traefik on servers"
task :stop do
on(MRSK_CONFIG.servers) { execute traefik.stop, raise_on_non_zero_exit: false }
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.stop, raise_on_non_zero_exit: false }
end
desc "Restart Traefik"
desc "Restart Traefik on servers"
task restart: %i[ stop start ]
desc "Display information about Traefik containers"
desc "Display information about Traefik containers from servers"
task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(traefik.info) + "\n\n" }
on(MRSK.config.role(:web).hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.info) + "\n\n" }
end
desc "Show last 100 log lines from Traefik on servers"
task :logs do
on(MRSK.config.hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.logs) + "\n\n" }
end
desc "Remove Traefik container and image from servers"
task remove: %i[ stop ] do
on(MRSK.config.role(:web).hosts) do
execute *MRSK.traefik.remove_container
execute *MRSK.traefik.remove_image
end
end
end
end

18
test/app_command_test.rb Normal file
View File

@@ -0,0 +1,18 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/app"
ENV["VERSION"] = "123"
ENV["RAILS_MASTER_KEY"] = "456"
class AppCommandTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config)
end
test "run" do
assert_equal \
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-123", "-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:123"], @app.run
end
end

View File

@@ -0,0 +1,24 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/builder"
class BuilderCommandTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
end
test "target multiarch by default" do
builder = Mrsk::Commands::Builder.new(Mrsk::Configuration.new(@config))
assert builder.multiarch?
end
test "target native when multiarch is off" do
builder = Mrsk::Commands::Builder.new(Mrsk::Configuration.new(@config.merge({ builder: { "multiarch" => false } })))
assert builder.native?
end
test "target multiarch remote when local and remote is set" do
builder = Mrsk::Commands::Builder.new(Mrsk::Configuration.new(@config.merge({ builder: { "local" => { }, "remote" => { } } })))
assert builder.remote?
end
end

12
test/commander_test.rb Normal file
View File

@@ -0,0 +1,12 @@
require "test_helper"
require "mrsk/commander"
class CommanderTest < ActiveSupport::TestCase
setup do
@mrsk = Mrsk::Commander.new config_file: Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__))
end
test "lazy configuration" do
assert_equal Mrsk::Configuration, @mrsk.config.class
end
end

View File

@@ -0,0 +1,61 @@
require "test_helper"
require "mrsk/configuration"
ENV["VERSION"] = "123"
class ConfigurationRoleTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => {
"hosts" => [ "1.1.1.3", "1.1.1.4" ],
"cmd" => "bin/jobs"
}
}
})
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end
test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts
end
test "cmd" do
assert_nil @config.role(:web).cmd
assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd
end
test "label args" do
assert_equal [ "--label", "service=app", "--label", "role=workers" ], @config_with_roles.role(:workers).label_args
end
test "special label args for web" do
assert_equal [ "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms"], @config.role(:web).label_args
end
test "custom labels" do
@deploy[:labels] = { "my.custom.label" => "50" }
assert_equal "50", @config.role(:web).labels["my.custom.label"]
end
test "custom labels via role specialization" do
@deploy_with_roles[:labels] = { "my.custom.label" => "50" }
@deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" }
assert_equal "70", @config_with_roles.role(:workers).labels["my.custom.label"]
end
test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app.rule" => "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'" }
assert_equal "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'", @config.role(:web).labels["traefik.http.routers.app.rule"]
end
end

View File

@@ -2,28 +2,116 @@ require "test_helper"
require "mrsk/configuration"
ENV["VERSION"] = "123"
ENV["RAILS_MASTER_KEY"] = "456"
class ConfigurationTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" } }
@deploy = {
service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" },
env: { "REDIS_URL" => "redis://x/y" },
servers: [ "1.1.1.1", "1.1.1.2" ]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ] } } })
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end
test "ensure valid keys" do
assert_raise(ArgumentError) do
Mrsk::Configuration.new(@config.tap { _1.delete(:service) })
Mrsk::Configuration.new(@config.tap { _1.delete(:image) })
Mrsk::Configuration.new(@config.tap { _1.delete(:registry) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("username") })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("password") })
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") })
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") })
end
end
test "roles" do
assert_equal %w[ web ], @config.roles.collect(&:name)
assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name)
end
test "role" do
assert_equal "web", @config.role(:web).name
assert_equal "workers", @config_with_roles.role(:workers).name
assert_nil @config.role(:missing)
end
test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2"], @config.hosts
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
end
test "hosts from ENV" do
ENV["HOSTS"] = "1.1.1.5,1.1.1.6"
assert_equal [ "1.1.1.5", "1.1.1.6"], @config.hosts
ensure
ENV["HOSTS"] = nil
end
test "hosts from ENV roles" do
ENV["ROLES"] = "web,workers"
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
ENV["ROLES"] = "workers"
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.hosts
ensure
ENV["ROLES"] = nil
end
test "primary host" do
assert_equal "1.1.1.1", @config.primary_host
assert_equal "1.1.1.1", @config_with_roles.primary_host
end
test "version" do
assert_equal "123", @config.version
end
test "repository" do
assert_equal "dhh/app", @config.repository
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app", config.repository
end
test "absolute image" do
configuration = Mrsk::Configuration.new(@config)
assert_equal "dhh/app:123", configuration.absolute_image
assert_equal "dhh/app:123", @config.absolute_image
configuration = Mrsk::Configuration.new(@config.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app:123", configuration.absolute_image
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app:123", config.absolute_image
end
test "service with version" do
assert_equal "app-123", @config.service_with_version
end
test "env args" do
assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args
end
test "ssh options" do
assert_equal "root", @config.ssh_options[:user]
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:ssh_user] = "app" })
assert_equal "app", @config.ssh_options[:user]
end
test "master key" do
assert_equal "456", @config.master_key
end
test "erb evaluation of yml config" do
config = Mrsk::Configuration.load_file Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__))
assert_equal "my-user", config.registry["username"]
end
end

11
test/fixtures/deploy.erb.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
service: app
image: dhh/app
servers:
- 1.1.1.1
- 1.1.1.2
env:
REDIS_URL: redis://x/y
registry:
server: registry.digitalocean.com
username: <%= "my-user" %>
password: <%= "my-password" %>

View File

@@ -1,6 +1,7 @@
require "bundler/setup"
require "active_support/test_case"
require "active_support/testing/autorun"
require "debug"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]