mrsk 0.12.1 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '099a4dc2dc59df4e0c5301b85a579970dbc6c46a3c1e2a634f4461c5cff1f241'
4
- data.tar.gz: 0a0356837992a9847b7b6bc51956c26a4fb0e8fbb9809753a5bdb373bacd3aee
3
+ metadata.gz: 6b258a4c44d5b010d6ab4004d0e051f9378dd4511203020a8fed2dc639527d9f
4
+ data.tar.gz: 702b1c42fc0b095d18c37baa1811186ece2267b3d49067648129b76bef284be1
5
5
  SHA512:
6
- metadata.gz: 06f1365b5f8a7cc2064f2bd184224dba12b1c0ebf57cc502c9ac423a9d0ae96a601161fe681764f9a660b8a6214f2a86b79d1c4d46d9ff13819c52159db14318
7
- data.tar.gz: 7acf9c5d2e26709b391a2c9915300c569fd6cc7d841b55cd3a497122d2d2317b8d2b3df4e634f14c38acd571fbc8aac51096a221f10097f8ce68d6c07dbce038
6
+ metadata.gz: 60163759e4097ea91df33411e7dc32261c0bc82f609bc1f54308f4111e3f0efe4d9731b234175e293f5c8a6aef9e1a88156be88260fc0a545b4d276ddb4e753b
7
+ data.tar.gz: 882d09991704e45f3c377dfafa0b15f2c6f1421085e5ca8096855bb03b109c6a91e4e47b7ec9270cdb31f7e43b3a18b23576fb0b2faa6367c82d9b3748550201
data/README.md CHANGED
@@ -44,24 +44,24 @@ Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSW
44
44
  Now you're ready to deploy to the servers:
45
45
 
46
46
  ```
47
- mrsk deploy
47
+ mrsk setup
48
48
  ```
49
49
 
50
50
  This will:
51
51
 
52
52
  1. Connect to the servers over SSH (using root by default, authenticated by your ssh key)
53
- 2. Install Docker on any server that might be missing it (using apt-get): root access is needed via ssh for this.
53
+ 2. Install Docker and curl on any server that might be missing it (using apt-get): root access is needed via ssh for this.
54
54
  3. Log into the registry both locally and remotely
55
55
  4. Build the image using the standard Dockerfile in the root of the application.
56
56
  5. Push the image to the registry.
57
57
  6. Pull the image from the registry onto the servers.
58
58
  7. Ensure Traefik is running and accepting traffic on port 80.
59
- 8. Ensure your app responds with `200 OK` to `GET /up`.
59
+ 8. Ensure your app responds with `200 OK` to `GET /up` (you must have curl installed inside your app image!).
60
60
  9. Start a new container with the version of the app that matches the current git version hash.
61
61
  10. Stop the old container running the previous version of the app.
62
62
  11. Prune unused images and stopped containers to ensure servers don't fill up.
63
63
 
64
- 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.
64
+ 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. For subsequent deploys, or if your servers already have Docker and curl installed, you can just run `mrsk deploy`.
65
65
 
66
66
  ## Vision
67
67
 
@@ -184,6 +184,19 @@ registry:
184
184
 
185
185
  A reference to secret `DOCKER_REGISTRY_TOKEN` will look for `ENV["DOCKER_REGISTRY_TOKEN"]` on the machine running MRSK.
186
186
 
187
+ #### Using AWS ECR as the container registry
188
+
189
+ AWS ECR's access token is only valid for 12hrs. In order to not have to manually regenerate the token every time, you can use ERB in the `deploy.yml` file to shell out to the `aws` cli command, and obtain the token:
190
+
191
+ ```yaml
192
+ registry:
193
+ server: <your aws account id>.dkr.ecr.<your aws region id>.amazonaws.com
194
+ username: AWS
195
+ password: <%= %x(aws ecr get-login-password) %>
196
+ ```
197
+
198
+ You will need to have the `aws` CLI installed locally for this to work.
199
+
187
200
  ### Using a different SSH user than root
188
201
 
189
202
  The default SSH user is root, but you can change it using `ssh/user`:
@@ -522,7 +535,7 @@ traefik:
522
535
  options:
523
536
  publish:
524
537
  - 8080:8080
525
- volumes:
538
+ volume:
526
539
  - /tmp/example.json:/tmp/example.json
527
540
  memory: 512m
528
541
  ```
@@ -655,43 +668,6 @@ servers:
655
668
 
656
669
  This assumes the Cron settings are stored in `config/crontab`.
657
670
 
658
- ### Using audit broadcasts
659
-
660
- If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument:
661
-
662
- ```yaml
663
- audit_broadcast_cmd:
664
- bin/audit_broadcast
665
- ```
666
-
667
- The broadcast command could look something like:
668
-
669
- ```bash
670
- #!/usr/bin/env bash
671
- curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
672
- ```
673
-
674
- That'll post a line like follows to a preconfigured chatbot in Basecamp:
675
-
676
- ```
677
- [My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
678
- ```
679
-
680
- `MRSK_*` environment variables are available to the broadcast command for
681
- fine-grained audit reporting, e.g. for triggering deployment reports or
682
- firing a JSON webhook. These variables include:
683
- - `MRSK_RECORDED_AT` - UTC timestamp in ISO 8601 format, e.g. `2023-04-14T17:07:31Z`
684
- - `MRSK_PERFORMER` - the local user performing the command (from `whoami`)
685
- - `MRSK_MESSAGE` - the full audit message, e.g. "Deployed app@150b24f"
686
- - `MRSK_DESTINATION` - optional: destination, e.g. "staging"
687
- - `MRSK_ROLE` - optional: role targeted, e.g. "web"
688
-
689
- Use `mrsk broadcast` to test and troubleshoot your broadcast command:
690
-
691
- ```bash
692
- mrsk broadcast -m "test audit message"
693
- ```
694
-
695
671
  ### Healthcheck
696
672
 
697
673
  MRSK uses Docker healtchecks to check the health of your application during deployment. Traefik uses this same healthcheck status to determine when a container is ready to receive traffic.
@@ -703,6 +679,7 @@ healthcheck:
703
679
  path: /healthz
704
680
  port: 4000
705
681
  max_attempts: 7
682
+ interval: 20s
706
683
  ```
707
684
 
708
685
  This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
@@ -888,6 +865,56 @@ When `limit` is specified, containers will be booted on, at most, `limit` hosts
888
865
 
889
866
  These settings only apply when booting containers (using `mrsk deploy`, or `mrsk app boot`). For other commands, MRSK continues to run commands in parallel across all hosts.
890
867
 
868
+ ## Hooks
869
+
870
+ You can run custom scripts at specific points with hooks.
871
+
872
+ Hooks should be stored in the .mrsk/hooks folder. Running mrsk init will build that folder and add some sample scripts.
873
+
874
+ You can change their location by setting `hooks_path` in the configuration file.
875
+
876
+ If the script returns a non-zero exit code the command will be aborted.
877
+
878
+ `MRSK_*` environment variables are available to the hooks command for
879
+ fine-grained audit reporting, e.g. for triggering deployment reports or
880
+ firing a JSON webhook. These variables include:
881
+ - `MRSK_RECORDED_AT` - UTC timestamp in ISO 8601 format, e.g. `2023-04-14T17:07:31Z`
882
+ - `MRSK_PERFORMER` - the local user performing the command (from `whoami`)
883
+ - `MRSK_SERVICE_VERSION` - an abbreviated service and version for use in messages, e.g. app@150b24f
884
+ - `MRSK_VERSION` - an full version being deployed
885
+ - `MRSK_DESTINATION` - optional: destination, e.g. "staging"
886
+ - `MRSK_HOSTS` - a comma separated list of the hosts targeted by the command
887
+ - `MRSK_ROLE` - optional: role targeted, e.g. "web"
888
+
889
+ There are three hooks:
890
+
891
+ 1. pre-connect
892
+ Called before taking the deploy lock. For checks that need to run before connecting to remote hosts - e.g. DNS warming.
893
+
894
+ 2. pre-build
895
+ Used for pre-build checks - e.g. there are no uncommitted changes or that CI has passed.
896
+
897
+ 3. post-deploy - run after a deploy, redeploy or rollback
898
+
899
+ This hook is also passed a `MRSK_RUNTIME` env variable.
900
+
901
+ This could be used to broadcast a deployment message, or register the new version with an APM.
902
+
903
+ The command could look something like:
904
+
905
+ ```bash
906
+ #!/usr/bin/env bash
907
+ curl -q -d content="[My App] ${MRSK_PERFORMER} Rolled back to version ${MRSK_VERSION}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
908
+ ```
909
+
910
+ That'll post a line like follows to a preconfigured chatbot in Basecamp:
911
+
912
+ ```
913
+ [My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
914
+ ```
915
+
916
+ Set `--skip_hooks` to avoid running the hooks.
917
+
891
918
  ## Stage of development
892
919
 
893
920
  This is beta software. Commands may still move around. But we're live in production at [37signals](https://37signals.com).
data/bin/mrsk CHANGED
@@ -8,7 +8,7 @@ require "mrsk"
8
8
  begin
9
9
  Mrsk::Cli::Main.start(ARGV)
10
10
  rescue SSHKit::Runner::ExecuteError => e
11
- puts " \e[31mERROR (#{e.cause.class}): #{e.cause.message}\e[0m"
11
+ puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m"
12
12
  puts e.cause.backtrace if ENV["VERBOSE"]
13
13
  exit 1
14
14
  rescue => e
@@ -14,8 +14,6 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
14
14
  execute *MRSK.auditor.record("Booted #{name} accessory"), verbosity: :debug
15
15
  execute *accessory.run
16
16
  end
17
-
18
- audit_broadcast "Booted accessory #{name}" unless options[:skip_broadcast]
19
17
  end
20
18
  end
21
19
  end
data/lib/mrsk/cli/app.rb CHANGED
@@ -2,37 +2,39 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
2
2
  desc "boot", "Boot app on servers (or reboot app if already running)"
3
3
  def boot
4
4
  with_lock do
5
- say "Get most recent version available as an image...", :magenta unless options[:version]
6
- using_version(version_or_latest) do |version|
7
- say "Start container with version #{version} using a #{MRSK.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
5
+ hold_lock_on_error do
6
+ say "Get most recent version available as an image...", :magenta unless options[:version]
7
+ using_version(version_or_latest) do |version|
8
+ say "Start container with version #{version} using a #{MRSK.config.readiness_delay}s readiness delay (or reboot if already running)...", :magenta
9
+
10
+ on(MRSK.hosts) do
11
+ execute *MRSK.auditor.record("Tagging #{MRSK.config.absolute_image} as the latest image"), verbosity: :debug
12
+ execute *MRSK.app.tag_current_as_latest
13
+ end
8
14
 
9
- on(MRSK.hosts) do
10
- execute *MRSK.auditor.record("Tagging #{MRSK.config.absolute_image} as the latest image"), verbosity: :debug
11
- execute *MRSK.app.tag_current_as_latest
12
- end
15
+ on(MRSK.hosts, **MRSK.boot_strategy) do |host|
16
+ roles = MRSK.roles_on(host)
13
17
 
14
- on(MRSK.hosts, **MRSK.boot_strategy) do |host|
15
- roles = MRSK.roles_on(host)
18
+ roles.each do |role|
19
+ app = MRSK.app(role: role)
20
+ auditor = MRSK.auditor(role: role)
16
21
 
17
- roles.each do |role|
18
- app = MRSK.app(role: role)
19
- auditor = MRSK.auditor(role: role)
20
-
21
- execute *auditor.record("Booted app version #{version}"), verbosity: :debug
22
+ if capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).present?
23
+ tmp_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
24
+ info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
25
+ execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
26
+ execute *app.rename_container(version: version, new_version: tmp_version)
27
+ end
22
28
 
23
- if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
24
- tmp_version = "#{version}_#{SecureRandom.hex(8)}"
25
- info "Renaming container #{version} to #{tmp_version} as already deployed on #{host}"
26
- execute *auditor.record("Renaming container #{version} to #{tmp_version}"), verbosity: :debug
27
- execute *app.rename_container(version: version, new_version: tmp_version)
28
- end
29
+ execute *auditor.record("Booted app version #{version}"), verbosity: :debug
29
30
 
30
- old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
31
- execute *app.run
31
+ old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
32
+ execute *app.start_or_run
32
33
 
33
- Mrsk::Utils::HealthcheckPoller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
34
+ Mrsk::Utils::HealthcheckPoller.wait_for_healthy(pause_after_ready: true) { capture_with_info(*app.status(version: version)) }
34
35
 
35
- execute *app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
36
+ execute *app.stop(version: old_version), raise_on_non_zero_exit: false if old_version.present?
37
+ end
36
38
  end
37
39
  end
38
40
  end
data/lib/mrsk/cli/base.rb CHANGED
@@ -20,7 +20,7 @@ module Mrsk::Cli
20
20
  class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file"
21
21
  class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)"
22
22
 
23
- class_option :skip_broadcast, aliases: "-B", type: :boolean, default: false, desc: "Skip audit broadcasts"
23
+ class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks"
24
24
 
25
25
  def initialize(*)
26
26
  super
@@ -72,14 +72,12 @@ module Mrsk::Cli
72
72
  puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
73
73
  end
74
74
 
75
- def audit_broadcast(line)
76
- run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
77
- end
78
-
79
75
  def with_lock
80
76
  if MRSK.holding_lock?
81
77
  yield
82
78
  else
79
+ run_hook "pre-connect"
80
+
83
81
  acquire_lock
84
82
 
85
83
  begin
@@ -99,26 +97,32 @@ module Mrsk::Cli
99
97
  end
100
98
 
101
99
  def acquire_lock
102
- say "Acquiring the deploy lock"
103
- on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version) }
100
+ raise_if_locked do
101
+ say "Acquiring the deploy lock...", :magenta
102
+ on(MRSK.primary_host) { execute *MRSK.lock.acquire("Automatic deploy lock", MRSK.config.version), verbosity: :debug }
103
+ end
104
104
 
105
105
  MRSK.holding_lock = true
106
+ end
107
+
108
+ def release_lock
109
+ say "Releasing the deploy lock...", :magenta
110
+ on(MRSK.primary_host) { execute *MRSK.lock.release, verbosity: :debug }
111
+
112
+ MRSK.holding_lock = false
113
+ end
114
+
115
+ def raise_if_locked
116
+ yield
106
117
  rescue SSHKit::Runner::ExecuteError => e
107
118
  if e.message =~ /cannot create directory/
108
- on(MRSK.primary_host) { execute *MRSK.lock.status }
119
+ on(MRSK.primary_host) { puts capture_with_debug(*MRSK.lock.status) }
109
120
  raise LockError, "Deploy lock found"
110
121
  else
111
122
  raise e
112
123
  end
113
124
  end
114
125
 
115
- def release_lock
116
- say "Releasing the deploy lock"
117
- on(MRSK.primary_host) { execute *MRSK.lock.release }
118
-
119
- MRSK.holding_lock = false
120
- end
121
-
122
126
  def hold_lock_on_error
123
127
  if MRSK.hold_lock_on_error?
124
128
  yield
@@ -128,5 +132,16 @@ module Mrsk::Cli
128
132
  MRSK.hold_lock_on_error = false
129
133
  end
130
134
  end
135
+
136
+ def run_hook(hook, **details)
137
+ if !options[:skip_hooks] && MRSK.hook.hook_exists?(hook)
138
+ say "Running the #{hook} hook...", :magenta
139
+ run_locally do
140
+ MRSK.with_verbosity(:debug) { execute *MRSK.hook.run(hook, **details, hosts: MRSK.hosts.join(",")) }
141
+ rescue SSHKit::Command::Failed
142
+ raise HookError.new("Hook `#{hook}` failed")
143
+ end
144
+ end
145
+ end
131
146
  end
132
147
  end
@@ -14,11 +14,12 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
14
14
  with_lock do
15
15
  cli = self
16
16
 
17
+ verify_local_dependencies
18
+ run_hook "pre-build"
19
+
17
20
  run_locally do
18
21
  begin
19
- if cli.verify_local_dependencies
20
- MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
21
- end
22
+ MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
22
23
  rescue SSHKit::Command::Failed => e
23
24
  if e.message =~ /(no builder)|(no such file or directory)/
24
25
  error "Missing compatible builder, so creating a new one first"
@@ -82,21 +83,18 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
82
83
  end
83
84
  end
84
85
 
86
+ private
87
+ def verify_local_dependencies
88
+ run_locally do
89
+ begin
90
+ execute *MRSK.builder.ensure_local_dependencies_installed
91
+ rescue SSHKit::Command::Failed => e
92
+ build_error = e.message =~ /command not found/ ?
93
+ "Docker is not installed locally" :
94
+ "Docker buildx plugin is not installed locally"
85
95
 
86
- desc "", "" # Really a private method, but needed to be invoked from #push
87
- def verify_local_dependencies
88
- run_locally do
89
- begin
90
- execute *MRSK.builder.ensure_local_dependencies_installed
91
- rescue SSHKit::Command::Failed => e
92
- build_error = e.message =~ /command not found/ ?
93
- "Docker is not installed locally" :
94
- "Docker buildx plugin is not installed locally"
95
-
96
- raise BuildError, build_error
96
+ raise BuildError, build_error
97
+ end
97
98
  end
98
99
  end
99
-
100
- true
101
- end
102
100
  end
data/lib/mrsk/cli/lock.rb CHANGED
@@ -2,7 +2,7 @@ class Mrsk::Cli::Lock < Mrsk::Cli::Base
2
2
  desc "status", "Report lock status"
3
3
  def status
4
4
  handle_missing_lock do
5
- on(MRSK.primary_host) { puts capture_with_info(*MRSK.lock.status) }
5
+ on(MRSK.primary_host) { puts capture_with_debug(*MRSK.lock.status) }
6
6
  end
7
7
  end
8
8
 
@@ -10,8 +10,8 @@ class Mrsk::Cli::Lock < Mrsk::Cli::Base
10
10
  option :message, aliases: "-m", type: :string, desc: "A lock mesasge", required: true
11
11
  def acquire
12
12
  message = options[:message]
13
- handle_missing_lock do
14
- on(MRSK.primary_host) { execute *MRSK.lock.acquire(message, MRSK.config.version) }
13
+ raise_if_locked do
14
+ on(MRSK.primary_host) { execute *MRSK.lock.acquire(message, MRSK.config.version), verbosity: :debug }
15
15
  say "Acquired the deploy lock"
16
16
  end
17
17
  end
@@ -19,7 +19,7 @@ class Mrsk::Cli::Lock < Mrsk::Cli::Base
19
19
  desc "release", "Release the deploy lock"
20
20
  def release
21
21
  handle_missing_lock do
22
- on(MRSK.primary_host) { execute *MRSK.lock.release }
22
+ on(MRSK.primary_host) { execute *MRSK.lock.release, verbosity: :debug }
23
23
  say "Released the deploy lock"
24
24
  end
25
25
  end
data/lib/mrsk/cli/main.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  class Mrsk::Cli::Main < Mrsk::Cli::Base
2
2
  desc "setup", "Setup all accessories and deploy app to servers"
3
3
  def setup
4
- with_lock do
5
- print_runtime do
4
+ print_runtime do
5
+ with_lock do
6
6
  invoke "mrsk:cli:server:bootstrap"
7
7
  invoke "mrsk:cli:accessory:boot", [ "all" ]
8
8
  deploy
@@ -13,10 +13,10 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
13
13
  desc "deploy", "Deploy app to servers"
14
14
  option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
15
15
  def deploy
16
- with_lock do
17
- invoke_options = deploy_options
16
+ runtime = print_runtime do
17
+ with_lock do
18
+ invoke_options = deploy_options
18
19
 
19
- runtime = print_runtime do
20
20
  say "Log into image registry...", :magenta
21
21
  invoke "mrsk:cli:registry:login", [], invoke_options
22
22
 
@@ -37,25 +37,23 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
37
37
  say "Detect stale containers...", :magenta
38
38
  invoke "mrsk:cli:app:stale_containers", [], invoke_options
39
39
 
40
- hold_lock_on_error do
41
- invoke "mrsk:cli:app:boot", [], invoke_options
42
- end
40
+ invoke "mrsk:cli:app:boot", [], invoke_options
43
41
 
44
42
  say "Prune old containers and images...", :magenta
45
43
  invoke "mrsk:cli:prune:all", [], invoke_options
46
44
  end
47
-
48
- audit_broadcast "Deployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
49
45
  end
46
+
47
+ run_hook "post-deploy", runtime: runtime.round
50
48
  end
51
49
 
52
50
  desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
53
51
  option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
54
52
  def redeploy
55
- with_lock do
56
- invoke_options = deploy_options
53
+ runtime = print_runtime do
54
+ with_lock do
55
+ invoke_options = deploy_options
57
56
 
58
- runtime = print_runtime do
59
57
  if options[:skip_push]
60
58
  say "Pull app image...", :magenta
61
59
  invoke "mrsk:cli:build:pull", [], invoke_options
@@ -70,55 +68,33 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
70
68
  say "Detect stale containers...", :magenta
71
69
  invoke "mrsk:cli:app:stale_containers", [], invoke_options
72
70
 
73
- hold_lock_on_error do
74
- invoke "mrsk:cli:app:boot", [], invoke_options
75
- end
71
+ invoke "mrsk:cli:app:boot", [], invoke_options
76
72
  end
77
-
78
- audit_broadcast "Redeployed #{service_version} in #{runtime.round} seconds" unless options[:skip_broadcast]
79
73
  end
74
+
75
+ run_hook "post-deploy", runtime: runtime.round
80
76
  end
81
77
 
82
78
  desc "rollback [VERSION]", "Rollback app to VERSION"
83
79
  def rollback(version)
84
- with_lock do
85
- invoke_options = deploy_options
80
+ rolled_back = false
81
+ runtime = print_runtime do
82
+ with_lock do
83
+ invoke_options = deploy_options
86
84
 
87
- hold_lock_on_error do
88
85
  MRSK.config.version = version
89
86
  old_version = nil
90
87
 
91
88
  if container_available?(version)
92
- say "Start version #{version}, then wait #{MRSK.config.readiness_delay}s for app to boot before stopping the old version...", :magenta
93
-
94
- on(MRSK.hosts) do
95
- execute *MRSK.auditor.record("Tagging #{MRSK.config.absolute_image} as the latest image"), verbosity: :debug
96
- execute *MRSK.app.tag_current_as_latest
97
- end
98
-
99
- on(MRSK.hosts) do |host|
100
- roles = MRSK.roles_on(host)
101
-
102
- roles.each do |role|
103
- app = MRSK.app(role: role)
104
- old_version = capture_with_info(*app.current_running_version).strip.presence
105
-
106
- execute *app.start
107
-
108
- if old_version
109
- sleep MRSK.config.readiness_delay
110
-
111
- execute *app.stop(version: old_version), raise_on_non_zero_exit: false
112
- end
113
- end
114
- end
115
-
116
- audit_broadcast "Rolled back #{service_version(Mrsk::Utils.abbreviate_version(old_version))} to #{service_version}" unless options[:skip_broadcast]
89
+ invoke "mrsk:cli:app:boot", [], invoke_options.merge(version: version)
90
+ rolled_back = true
117
91
  else
118
92
  say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
119
93
  end
120
94
  end
121
95
  end
96
+
97
+ run_hook "post-deploy", runtime: runtime.round if rolled_back
122
98
  end
123
99
 
124
100
  desc "details", "Show details about all containers"
@@ -160,6 +136,14 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
160
136
  puts "Created .env file"
161
137
  end
162
138
 
139
+ unless (hooks_dir = Pathname.new(File.expand_path(".mrsk/hooks"))).exist?
140
+ hooks_dir.mkpath
141
+ Pathname.new(File.expand_path("templates/sample_hooks", __dir__)).each_child do |sample_hook|
142
+ FileUtils.cp sample_hook, hooks_dir, preserve: true
143
+ end
144
+ puts "Created sample hooks in .mrsk/hooks"
145
+ end
146
+
163
147
  if options[:bundle]
164
148
  if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
165
149
  puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
@@ -200,13 +184,6 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
200
184
  end
201
185
  end
202
186
 
203
- desc "broadcast", "Broadcast an audit message"
204
- option :message, aliases: "-m", type: :string, desc: "Audit mesasge", required: true
205
- def broadcast
206
- say "Broadcast: #{options[:message]}", :magenta
207
- audit_broadcast options[:message]
208
- end
209
-
210
187
  desc "version", "Show MRSK version"
211
188
  def version
212
189
  puts Mrsk::VERSION
@@ -224,6 +201,9 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
224
201
  desc "healthcheck", "Healthcheck application"
225
202
  subcommand "healthcheck", Mrsk::Cli::Healthcheck
226
203
 
204
+ desc "lock", "Manage the deploy lock"
205
+ subcommand "lock", Mrsk::Cli::Lock
206
+
227
207
  desc "prune", "Prune old application images and containers"
228
208
  subcommand "prune", Mrsk::Cli::Prune
229
209
 
@@ -236,9 +216,6 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
236
216
  desc "traefik", "Manage Traefik load balancer"
237
217
  subcommand "traefik", Mrsk::Cli::Traefik
238
218
 
239
- desc "lock", "Manage the deploy lock"
240
- subcommand "lock", Mrsk::Cli::Lock
241
-
242
219
  private
243
220
  def container_available?(version)
244
221
  begin
@@ -263,8 +240,4 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
263
240
  def deploy_options
264
241
  { "version" => MRSK.config.version }.merge(options.without("skip_push"))
265
242
  end
266
-
267
- def service_version(version = MRSK.config.abbreviated_version)
268
- [ MRSK.config.service, version ].compact.join("@")
269
- end
270
243
  end
@@ -25,10 +25,6 @@ registry:
25
25
  # secret:
26
26
  # - RAILS_MASTER_KEY
27
27
 
28
- # Call a broadcast command on deploys.
29
- # audit_broadcast_cmd:
30
- # bin/broadcast_to_bc
31
-
32
28
  # Use a different ssh user than root
33
29
  # ssh:
34
30
  # user: app
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+
3
+ # A sample post-deploy hook
4
+ #
5
+ # These environment variables are available:
6
+ # MRSK_RECORDED_AT
7
+ # MRSK_PERFORMER
8
+ # MRSK_VERSION
9
+ # MRSK_HOSTS
10
+ # MRSK_ROLE (if set)
11
+ # MRSK_DESTINATION (if set)
12
+ # MRSK_RUNTIME
13
+
14
+ echo "$MRSK_PERFORMER deployed $MRSK_VERSION to $MRSK_DESTINATION in $MRSK_RUNTIME seconds"
@@ -0,0 +1,51 @@
1
+ #!/bin/sh
2
+
3
+ # A sample pre-build hook
4
+ #
5
+ # Checks:
6
+ # 1. We have a clean checkout
7
+ # 2. A remote is configured
8
+ # 3. The branch has been pushed to the remote
9
+ # 4. The version we are deploying matches the remote
10
+ #
11
+ # These environment variables are available:
12
+ # MRSK_RECORDED_AT
13
+ # MRSK_PERFORMER
14
+ # MRSK_VERSION
15
+ # MRSK_HOSTS
16
+ # MRSK_ROLE (if set)
17
+ # MRSK_DESTINATION (if set)
18
+
19
+ if [ -n "$(git status --porcelain)" ]; then
20
+ echo "Git checkout is not clean, aborting..." >&2
21
+ git status --porcelain >&2
22
+ exit 1
23
+ fi
24
+
25
+ first_remote=$(git remote)
26
+
27
+ if [ -z "$first_remote" ]; then
28
+ echo "No git remote set, aborting..." >&2
29
+ exit 1
30
+ fi
31
+
32
+ current_branch=$(git branch --show-current)
33
+
34
+ if [ -z "$current_branch" ]; then
35
+ echo "No git remote set, aborting..." >&2
36
+ exit 1
37
+ fi
38
+
39
+ remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
40
+
41
+ if [ -z "$remote_head" ]; then
42
+ echo "Branch not pushed to remote, aborting..." >&2
43
+ exit 1
44
+ fi
45
+
46
+ if [ "$MRSK_VERSION" != "$remote_head" ]; then
47
+ echo "Version ($MRSK_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
48
+ exit 1
49
+ fi
50
+
51
+ exit 0
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # A sample pre-connect check
4
+ #
5
+ # Warms DNS before connecting to hosts in parallel
6
+ #
7
+ # These environment variables are available:
8
+ # MRSK_RECORDED_AT
9
+ # MRSK_PERFORMER
10
+ # MRSK_VERSION
11
+ # MRSK_HOSTS
12
+ # MRSK_ROLE (if set)
13
+ # MRSK_DESTINATION (if set)
14
+ # MRSK_RUNTIME
15
+
16
+ hosts = ENV["MRSK_HOSTS"].split(",")
17
+ results = nil
18
+ max = 3
19
+
20
+ elapsed = Benchmark.realtime do
21
+ results = hosts.map do |host|
22
+ Thread.new do
23
+ tries = 1
24
+
25
+ begin
26
+ Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
27
+ rescue SocketError
28
+ if tries < max
29
+ puts "Retrying DNS warmup: #{host}"
30
+ tries += 1
31
+ sleep rand
32
+ retry
33
+ else
34
+ puts "DNS warmup failed: #{host}"
35
+ host
36
+ end
37
+ end
38
+
39
+ tries
40
+ end
41
+ end.map(&:value)
42
+ end
43
+
44
+ retries = results.sum - hosts.size
45
+ nopes = results.count { |r| r == max }
46
+
47
+ puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]
data/lib/mrsk/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  module Mrsk::Cli
2
2
  class LockError < StandardError; end
3
+ class HookError < StandardError; end
3
4
  end
4
5
 
5
6
  # SSHKit uses instance eval, so we need a global const for ergonomics
@@ -100,6 +100,14 @@ class Mrsk::Commander
100
100
  @healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
101
101
  end
102
102
 
103
+ def hook
104
+ @hook ||= Mrsk::Commands::Hook.new(config)
105
+ end
106
+
107
+ def lock
108
+ @lock ||= Mrsk::Commands::Lock.new(config)
109
+ end
110
+
103
111
  def prune
104
112
  @prune ||= Mrsk::Commands::Prune.new(config)
105
113
  end
@@ -112,10 +120,6 @@ class Mrsk::Commander
112
120
  @traefik ||= Mrsk::Commands::Traefik.new(config)
113
121
  end
114
122
 
115
- def lock
116
- @lock ||= Mrsk::Commands::Lock.new(config)
117
- end
118
-
119
123
  def with_verbosity(level)
120
124
  old_level = self.verbosity
121
125
 
@@ -6,6 +6,10 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
6
6
  @role = role
7
7
  end
8
8
 
9
+ def start_or_run
10
+ combine start, run, by: "||"
11
+ end
12
+
9
13
  def run
10
14
  role = config.role(self.role)
11
15
 
@@ -91,8 +95,8 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
91
95
  docker :ps, "--quiet", *filter_args(status: :running), "--latest"
92
96
  end
93
97
 
94
- def container_id_for_version(version)
95
- container_id_for(container_name: container_name(version))
98
+ def container_id_for_version(version, only_running: false)
99
+ container_id_for(container_name: container_name(version), only_running: only_running)
96
100
  end
97
101
 
98
102
  def current_running_version
@@ -1,27 +1,18 @@
1
- require "time"
2
-
3
1
  class Mrsk::Commands::Auditor < Mrsk::Commands::Base
4
2
  attr_reader :details
5
3
 
6
4
  def initialize(config, **details)
7
5
  super(config)
8
- @details = default_details.merge(details)
6
+ @details = details
9
7
  end
10
8
 
11
9
  # Runs remotely
12
10
  def record(line, **details)
13
11
  append \
14
- [ :echo, *audit_tags(**details), line ],
12
+ [ :echo, audit_tags(**details).except(:version, :service_version).to_s, line ],
15
13
  audit_log_file
16
14
  end
17
15
 
18
- # Runs locally
19
- def broadcast(line, **details)
20
- if broadcast_cmd = config.audit_broadcast_cmd
21
- [ broadcast_cmd, *broadcast_args(line, **details), env: env_for(event: line, **details) ]
22
- end
23
- end
24
-
25
16
  def reveal
26
17
  [ :tail, "-n", 50, audit_log_file ]
27
18
  end
@@ -31,29 +22,7 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
31
22
  [ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-")
32
23
  end
33
24
 
34
- def default_details
35
- { recorded_at: Time.now.utc.iso8601,
36
- performer: `whoami`.chomp,
37
- destination: config.destination }
38
- end
39
-
40
25
  def audit_tags(**details)
41
- tags_for **self.details.merge(details)
42
- end
43
-
44
- def broadcast_args(line, **details)
45
- "'#{broadcast_tags(**details).join(" ")} #{line}'"
46
- end
47
-
48
- def broadcast_tags(**details)
49
- tags_for **self.details.merge(details).except(:recorded_at)
50
- end
51
-
52
- def tags_for(**details)
53
- details.compact.values.map { |value| "[#{value}]" }
54
- end
55
-
56
- def env_for(**details)
57
- self.details.merge(details).compact.transform_keys { |detail| "MRSK_#{detail.upcase}" }
26
+ tags(**self.details, **details)
58
27
  end
59
28
  end
@@ -18,8 +18,8 @@ module Mrsk::Commands
18
18
  end
19
19
  end
20
20
 
21
- def container_id_for(container_name:)
22
- docker :container, :ls, "--all", "--filter", "name=^#{container_name}$", "--quiet"
21
+ def container_id_for(container_name:, only_running: false)
22
+ docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet"
23
23
  end
24
24
 
25
25
  private
@@ -53,5 +53,9 @@ module Mrsk::Commands
53
53
  def docker(*args)
54
54
  args.compact.unshift :docker
55
55
  end
56
+
57
+ def tags(**details)
58
+ Mrsk::Tags.from_config(config, **details)
59
+ end
56
60
  end
57
61
  end
@@ -0,0 +1,14 @@
1
+ class Mrsk::Commands::Hook < Mrsk::Commands::Base
2
+ def run(hook, **details)
3
+ [ hook_file(hook), env: tags(**details).env ]
4
+ end
5
+
6
+ def hook_exists?(hook)
7
+ Pathname.new(hook_file(hook)).exist?
8
+ end
9
+
10
+ private
11
+ def hook_file(hook)
12
+ "#{config.hooks_path}/#{hook}"
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  class Mrsk::Commands::Traefik < Mrsk::Commands::Base
2
- delegate :argumentize, :optionize, to: Mrsk::Utils
2
+ delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
3
3
 
4
4
  DEFAULT_IMAGE = "traefik:v2.9"
5
5
  CONTAINER_PORT = 80
@@ -10,6 +10,7 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
10
10
  "--restart", "unless-stopped",
11
11
  "--publish", port,
12
12
  "--volume", "/var/run/docker.sock:/var/run/docker.sock",
13
+ *env_args,
13
14
  *config.logging_args,
14
15
  *label_args,
15
16
  *docker_options_args,
@@ -61,6 +62,16 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
61
62
  argumentize "--label", labels
62
63
  end
63
64
 
65
+ def env_args
66
+ env_config = config.traefik["env"] || {}
67
+
68
+ if env_config.present?
69
+ argumentize_env_with_secrets(env_config)
70
+ else
71
+ []
72
+ end
73
+ end
74
+
64
75
  def labels
65
76
  config.traefik["labels"] || []
66
77
  end
@@ -37,7 +37,7 @@ class Mrsk::Configuration::Role
37
37
 
38
38
  def health_check_args
39
39
  if health_check_cmd.present?
40
- optionize({ "health-cmd" => health_check_cmd, "health-interval" => "1s" })
40
+ optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
41
41
  else
42
42
  []
43
43
  end
@@ -50,6 +50,13 @@ class Mrsk::Configuration::Role
50
50
  options["cmd"] || http_health_check(port: options["port"], path: options["path"])
51
51
  end
52
52
 
53
+ def health_check_interval
54
+ options = specializations["healthcheck"] || {}
55
+ options = config.healthcheck.merge(options) if running_traefik?
56
+
57
+ options["interval"] || "1s"
58
+ end
59
+
53
60
  def cmd
54
61
  specializations["cmd"]
55
62
  end
@@ -6,7 +6,7 @@ require "erb"
6
6
  require "net/ssh/proxy/jump"
7
7
 
8
8
  class Mrsk::Configuration
9
- delegate :service, :image, :servers, :env, :labels, :registry, :builder, :stop_wait_time, to: :raw_config, allow_nil: true
9
+ delegate :service, :image, :servers, :env, :labels, :registry, :builder, :stop_wait_time, :hooks_path, to: :raw_config, allow_nil: true
10
10
  delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Mrsk::Utils
11
11
 
12
12
  attr_accessor :destination
@@ -50,7 +50,7 @@ class Mrsk::Configuration
50
50
  end
51
51
 
52
52
  def version
53
- @declared_version.presence || ENV["VERSION"] || current_commit_hash
53
+ @declared_version.presence || ENV["VERSION"] || git_version
54
54
  end
55
55
 
56
56
  def abbreviated_version
@@ -157,10 +157,6 @@ class Mrsk::Configuration
157
157
  end
158
158
 
159
159
 
160
- def audit_broadcast_cmd
161
- raw_config.audit_broadcast_cmd
162
- end
163
-
164
160
  def healthcheck
165
161
  { "path" => "/up", "port" => 3000, "max_attempts" => 7 }.merge(raw_config.healthcheck || {})
166
162
  end
@@ -197,6 +193,10 @@ class Mrsk::Configuration
197
193
  raw_config.traefik || {}
198
194
  end
199
195
 
196
+ def hooks_path
197
+ raw_config.hooks_path || ".mrsk/hooks"
198
+ end
199
+
200
200
  private
201
201
  # Will raise ArgumentError if any required config keys are missing
202
202
  def ensure_required_keys_present
@@ -233,10 +233,12 @@ class Mrsk::Configuration
233
233
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
234
234
  end
235
235
 
236
- def current_commit_hash
237
- @current_commit_hash ||=
236
+ def git_version
237
+ @git_version ||=
238
238
  if system("git rev-parse")
239
- `git rev-parse HEAD`.strip
239
+ uncommitted_suffix = `git status --porcelain`.strip.present? ? "_uncommitted_#{SecureRandom.hex(8)}" : ""
240
+
241
+ "#{`git rev-parse HEAD`.strip}#{uncommitted_suffix}"
240
242
  else
241
243
  raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
242
244
  end
@@ -8,6 +8,10 @@ class SSHKit::Backend::Abstract
8
8
  capture(*args, **kwargs, verbosity: Logger::INFO)
9
9
  end
10
10
 
11
+ def capture_with_debug(*args, **kwargs)
12
+ capture(*args, **kwargs, verbosity: Logger::DEBUG)
13
+ end
14
+
11
15
  def capture_with_pretty_json(*args, **kwargs)
12
16
  JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
13
17
  end
data/lib/mrsk/tags.rb ADDED
@@ -0,0 +1,39 @@
1
+ require "time"
2
+
3
+ class Mrsk::Tags
4
+ attr_reader :config, :tags
5
+
6
+ class << self
7
+ def from_config(config, **extra)
8
+ new(**default_tags(config), **extra)
9
+ end
10
+
11
+ def default_tags(config)
12
+ { recorded_at: Time.now.utc.iso8601,
13
+ performer: `whoami`.chomp,
14
+ destination: config.destination,
15
+ version: config.version,
16
+ service_version: service_version(config) }
17
+ end
18
+
19
+ def service_version(config)
20
+ [ config.service, config.abbreviated_version ].compact.join("@")
21
+ end
22
+ end
23
+
24
+ def initialize(**tags)
25
+ @tags = tags.compact
26
+ end
27
+
28
+ def env
29
+ tags.transform_keys { |detail| "MRSK_#{detail.upcase}" }
30
+ end
31
+
32
+ def to_s
33
+ tags.values.map { |value| "[#{value}]" }.join(" ")
34
+ end
35
+
36
+ def except(*tags)
37
+ self.class.new(**self.tags.except(*tags))
38
+ end
39
+ end
data/lib/mrsk/utils.rb CHANGED
@@ -84,6 +84,13 @@ module Mrsk::Utils
84
84
 
85
85
  # Abbreviate a git revhash for concise display
86
86
  def abbreviate_version(version)
87
- version[0...7] if version
87
+ if version
88
+ # Don't abbreviate <sha>_uncommitted_<etc>
89
+ if version.include?("_")
90
+ version
91
+ else
92
+ version[0...7]
93
+ end
94
+ end
88
95
  end
89
96
  end
data/lib/mrsk/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mrsk
2
- VERSION = "0.12.1"
2
+ VERSION = "0.13.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mrsk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-05 00:00:00.000000000 Z
11
+ date: 2023-05-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -187,6 +187,9 @@ files:
187
187
  - lib/mrsk/cli/registry.rb
188
188
  - lib/mrsk/cli/server.rb
189
189
  - lib/mrsk/cli/templates/deploy.yml
190
+ - lib/mrsk/cli/templates/sample_hooks/post-deploy.sample
191
+ - lib/mrsk/cli/templates/sample_hooks/pre-build.sample
192
+ - lib/mrsk/cli/templates/sample_hooks/pre-connect.sample
190
193
  - lib/mrsk/cli/templates/template.env
191
194
  - lib/mrsk/cli/traefik.rb
192
195
  - lib/mrsk/commander.rb
@@ -203,6 +206,7 @@ files:
203
206
  - lib/mrsk/commands/builder/native/remote.rb
204
207
  - lib/mrsk/commands/docker.rb
205
208
  - lib/mrsk/commands/healthcheck.rb
209
+ - lib/mrsk/commands/hook.rb
206
210
  - lib/mrsk/commands/lock.rb
207
211
  - lib/mrsk/commands/prune.rb
208
212
  - lib/mrsk/commands/registry.rb
@@ -212,6 +216,7 @@ files:
212
216
  - lib/mrsk/configuration/boot.rb
213
217
  - lib/mrsk/configuration/role.rb
214
218
  - lib/mrsk/sshkit_with_ext.rb
219
+ - lib/mrsk/tags.rb
215
220
  - lib/mrsk/utils.rb
216
221
  - lib/mrsk/utils/healthcheck_poller.rb
217
222
  - lib/mrsk/utils/sensitive.rb