Compare commits

...

55 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
31 changed files with 599 additions and 161 deletions

View File

@@ -1,7 +1,7 @@
PATH
remote: .
specs:
mrsk (0.0.2)
mrsk (0.0.3)
railties (>= 7.0.0)
sshkit (~> 1.21)

153
README.md
View File

@@ -1,22 +1,18 @@
# MRSK
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 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
Add the gem with `bundle add mrsk`, then run `rake mrsk:init`, and then edit the new file in `config/deploy.yml` to use the proper service name, image reference, servers to deploy on, and so on. It could look something 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: hey
image: 37s/hey
servers:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
env:
DATABASE_URL: mysql2://db1/hey_production/
REDIS_URL: redis://redis1:6379/1
- 192.168.0.1
- 192.168.0.2
registry:
server: registry.digitalocean.com
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>
```
@@ -26,13 +22,13 @@ Then ensure your encrypted credentials have the registry username + password by
```
registry:
username: real-user-name
password: real-password
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:
@@ -48,33 +44,123 @@ This will:
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.
## Operations
## Configuration
### Running job hosts separately
### Using another registry than Docker Hub
If your application uses separate job running hosts, or other roles beyond the default web running, you can specify these hosts and their custom command like so:
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:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
```
The application will be deployed to all hosts, but only those in the `web` role will be labeled to run under traefik. If you want to run custom commands on all hosts in a role, you can use `rake mrsk:app:exec:rails CMD=about ROLES=job`.
Traefik will only be installed and run on the servers in the `web` role (and on all servers if no roles are defined).
### Executing commands
### Adding custom container labels
If you need to execute commands inside the Rails containers, you can use `rake mrsk:app:exec`, `rake mrsk:app:exec:once`, `rake mrsk:app:exec:rails`, and `rake mrsk:app:exec:once:rails`. Examples:
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
rake mrsk:app:exec CMD='ruby -v'
./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]
@@ -82,11 +168,11 @@ App Host: xxx.xxx.xxx.xxx
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
# Runs command on first server
rake mrsk:app:exec:once CMD='cat .ruby-version'
./bin/mrsk app:exec:once CMD='cat .ruby-version'
3.1.3
# Runs Rails command on all servers
rake mrsk:app:exec:rails CMD=about
./bin/mrsk app:exec:rails CMD=about
App Host: xxx.xxx.xxx.xxx
About your application's environment
Rails version 7.1.0.alpha
@@ -112,14 +198,18 @@ Database adapter sqlite3
Database schema version 20221231233303
# Runs Rails command on first server
rake mrsk:app:exec:once:rails CMD='db:version'
./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 `rake mrsk:info`. It'll show something like this:
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
@@ -139,11 +229,11 @@ CONTAINER ID IMAGE
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 `rake mrsk:app:info` or just for Traefik with `rake mrsk:traefik:info`.
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 `rake mrsk:app:containers`. It'll give you a presentation similar to `rake mrsk:app:info`, but include all the old containers as well. Showing something like this:
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
@@ -157,20 +247,19 @@ badb1aa51db4 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483
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 `rake 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.
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 `rake mrsk:deploy`.
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 `rake mrsk:remove`. This will leave the servers clean.
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:
- Adapterize commands to work with Podman and other container runners
- Possibly switching to a bin/mrsk command rather than raw rake
- Integrate with cloud CI pipelines
## License

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,25 +1,2 @@
require "sshkit"
module Mrsk::Commands
class Base
attr_accessor :config
def initialize(config)
@config = config
end
private
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
require "mrsk/commands/app"
require "mrsk/commands/traefik"
require "mrsk/commands/registry"

View File

@@ -1,14 +1,6 @@
require "mrsk/commands/base"
class Mrsk::Commands::App < Mrsk::Commands::Base
def push
# TODO: Run 'docker buildx create --use' when needed
# TODO: Make multiarch an option so Linux users can enjoy speedier builds
docker :buildx, :build, "--push", "--platform linux/amd64,linux/arm64", "-t", config.absolute_image, "."
end
def pull
docker :pull, config.absolute_image
end
def run(role: :web)
role = config.role(role)
@@ -36,17 +28,22 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end
def logs
[ "docker ps -q #{service_filter.join(" ")} | xargs docker logs -f" ]
[ "docker ps -q #{service_filter.join(" ")} | xargs docker logs -n 100 -t" ]
end
def exec(*command)
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

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

View File

@@ -1,3 +1,5 @@
require "mrsk/commands/base"
class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
@@ -22,7 +24,7 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
end
def logs
docker :logs, "traefik"
docker :logs, "traefik", "-n", "100", "-t"
end
def remove_container

View File

@@ -3,7 +3,7 @@ require "active_support/core_ext/string/inquiry"
require "erb"
class Mrsk::Configuration
delegate :service, :image, :servers, :env, :registry, :ssh_user, to: :config, allow_nil: true
delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :config, allow_nil: true
class << self
def load_file(file)
@@ -75,11 +75,19 @@ class Mrsk::Configuration
def env_args
self.class.argumentize "-e", config.env if config.env.present?
if config.env.present?
self.class.argumentize "-e", config.env
else
[]
end
end
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

View File

@@ -11,6 +11,14 @@ class Mrsk::Configuration::Role
@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
@@ -31,14 +39,6 @@ class Mrsk::Configuration::Role
end
end
def labels
if name.web?
default_labels.merge(traefik_labels)
else
default_labels
end
end
def default_labels
{ "service" => config.service, "role" => name }
end
@@ -53,6 +53,13 @@ class Mrsk::Configuration::Role
}
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)
{ }

View File

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

View File

@@ -1,32 +1,17 @@
require_relative "setup"
app = Mrsk::Commands::App.new(MRSK_CONFIG)
namespace :mrsk do
namespace :app 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 { execute *app.push } unless ENV["VERSION"]
end
desc "Pull app image from the registry onto servers"
task :pull do
on(MRSK_CONFIG.hosts) { execute *app.pull }
end
desc "Run app on servers (or start them if they've already been run)"
task :run do
MRSK_CONFIG.roles.each do |role|
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
begin
execute *app.run(role: role.name)
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
puts "Container with same version already deployed on #{host}, starting that instead"
execute *app.start, host: host
error "Container with same version already deployed on #{host}, starting that instead"
execute *MRSK.app.start, host: host
else
raise
end
@@ -35,14 +20,14 @@ namespace :mrsk do
end
end
desc "Start existing app on servers"
desc "Start existing app on servers (use VERSION=<git-hash> to designate which version)"
task :start do
on(MRSK_CONFIG.hosts) { execute *app.start, raise_on_non_zero_exit: false }
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.hosts) { 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 "Start app on servers (use VERSION=<git-hash> to designate which version)"
@@ -50,48 +35,62 @@ namespace :mrsk do
desc "Display information about app containers"
task :info do
on(MRSK_CONFIG.hosts) { |host| puts "App 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(*app.exec(ENV["CMD"])) + "\n\n" }
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(*app.exec("bin/rails", ENV["CMD"])) + "\n\n" }
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) { |host| puts capture(*app.exec(ENV["CMD"])) }
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(*app.exec("bin/rails", ENV["CMD"])) }
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(*app.list_containers) + "\n\n" }
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.list_containers) + "\n\n" }
end
desc "Tail logs from app containers"
desc "Show last 100 log lines from app on servers"
task :logs do
on(MRSK_CONFIG.hosts) { execute *app.logs }
# 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: %i[ stop ] do
on(MRSK_CONFIG.hosts) do
execute *app.remove_containers
execute *app.remove_images
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

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

@@ -2,10 +2,10 @@ require_relative "setup"
namespace :mrsk do
desc "Deploy app for the first time to a fresh server"
task fresh: %w[ server:bootstrap registry:login app:deliver traefik:run app:stop app:run ]
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: %w[ registry:login app:deliver traefik:run app:stop app:run prune ]
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 ]
@@ -16,9 +16,20 @@ namespace :mrsk do
desc "Create config stub in config/deploy.yml"
task :init do
require "fileutils"
FileUtils.cp_r \
Pathname.new(File.expand_path("templates/deploy.yml", __dir__)),
Rails.root.join("config/deploy.yml")
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"

View File

@@ -7,12 +7,12 @@ namespace :mrsk do
namespace :prune do
desc "Prune unused images older than 30 days"
task :images do
on(MRSK_CONFIG.hosts) { execute "docker image prune -f --filter 'until=720h'" }
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) { execute "docker container prune -f --filter label=service=#{MRSK_CONFIG.service} --filter 'until=72h'" }
on(MRSK.config.hosts) { MRSK.verbosity(:debug) { execute *MRSK.prune.containers } }
end
end
end

View File

@@ -1,18 +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.hosts) { 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 *registry.logout }
on(MRSK.config.hosts) { execute *MRSK.registry.logout }
end
end
end

View File

@@ -5,7 +5,7 @@ namespace :mrsk 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" }
on(MRSK.config.hosts) { execute "apt-get install docker.io -y" }
end
end
end

View File

@@ -3,14 +3,4 @@ require "sshkit/dsl"
include SSHKit::DSL
if (config_file = Rails.root.join("config/deploy.yml")).exist?
MRSK_CONFIG = Mrsk::Configuration.load_file(config_file)
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = MRSK_CONFIG.ssh_options }
# No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.command_map[:docker] = "docker"
else
# MRSK is missing config/deploy.yml run 'rake mrsk:init'
MRSK_CONFIG = Mrsk::Configuration.new({}, validate: false)
end
MRSK = Mrsk::Commander.new config_file: Rails.root.join("config/deploy.yml"), verbose: ENV["VERBOSE"]

View File

@@ -3,19 +3,22 @@
service: my-app
# Name of the container image
image: user/chat
image: user/my-app
# All the servers targeted for deploy. You can reference a single server for a command by using SERVERS=xxx.xxx.xxx.xxx
# All the servers targeted for deploy. You can reference a single server for a command by using SERVERS=192.168.0.1
servers:
- xxx.xxx.xxx.xxx
- 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 / ...
username: <%= Rails.application.credentials.registry["username"] %>
password: <%= Rails.application.credentials.registry["password"] %>
# 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,22 +1,20 @@
require_relative "setup"
traefik = Mrsk::Commands::Traefik.new(MRSK_CONFIG)
namespace :mrsk do
namespace :traefik do
desc "Run Traefik on servers"
task :run do
on(MRSK_CONFIG.role(:web).hosts) { execute *traefik.run, raise_on_non_zero_exit: false }
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.role(:web).hosts) { 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 on servers"
task :stop do
on(MRSK_CONFIG.role(:web).hosts) { 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 on servers"
@@ -24,14 +22,19 @@ namespace :mrsk do
desc "Display information about Traefik containers from servers"
task :info do
on(MRSK_CONFIG.role(:web).hosts) { |host| puts "Traefik 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 *traefik.remove_container
execute *traefik.remove_image
on(MRSK.config.role(:web).hosts) do
execute *MRSK.traefik.remove_container
execute *MRSK.traefik.remove_image
end
end
end

View File

@@ -1,6 +1,6 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands"
require "mrsk/commands/app"
ENV["VERSION"] = "123"
ENV["RAILS_MASTER_KEY"] = "456"

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

@@ -30,6 +30,11 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
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
@@ -38,8 +43,19 @@ class ConfigurationRoleTest < ActiveSupport::TestCase
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 "cmd" do
assert_nil @config.role(:web).cmd
assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd
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