dash 2.12.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/app/boot.rb +47 -11
  3. data/lib/kamal/cli/app/rollout_boot.rb +59 -0
  4. data/lib/kamal/cli/app/ssl_certificates.rb +12 -3
  5. data/lib/kamal/cli/app.rb +74 -7
  6. data/lib/kamal/cli/base.rb +41 -32
  7. data/lib/kamal/cli/doctor/config_checks.rb +28 -0
  8. data/lib/kamal/cli/doctor/endpoint_checks.rb +114 -0
  9. data/lib/kamal/cli/doctor/host_checks.rb +178 -0
  10. data/lib/kamal/cli/doctor.rb +112 -0
  11. data/lib/kamal/cli/healthcheck/drift_error.rb +7 -0
  12. data/lib/kamal/cli/healthcheck/poller.rb +60 -10
  13. data/lib/kamal/cli/main.rb +63 -0
  14. data/lib/kamal/cli/proxy/drift.rb +39 -0
  15. data/lib/kamal/cli/proxy/loadbalancer_claim.rb +70 -0
  16. data/lib/kamal/cli/proxy/loadbalancer_reboot.rb +41 -0
  17. data/lib/kamal/cli/proxy/reboot.rb +172 -0
  18. data/lib/kamal/cli/proxy.rb +200 -26
  19. data/lib/kamal/cli/prune.rb +7 -4
  20. data/lib/kamal/cli/templates/sample_hooks/post-app-stop.sample +9 -0
  21. data/lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample +3 -0
  22. data/lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample +12 -0
  23. data/lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample +3 -0
  24. data/lib/kamal/cli.rb +1 -0
  25. data/lib/kamal/commands/app/proxy.rb +12 -0
  26. data/lib/kamal/commands/app.rb +8 -0
  27. data/lib/kamal/commands/base.rb +11 -1
  28. data/lib/kamal/commands/docker.rb +5 -0
  29. data/lib/kamal/commands/loadbalancer.rb +76 -34
  30. data/lib/kamal/commands/proxy.rb +105 -11
  31. data/lib/kamal/commands/prune.rb +16 -2
  32. data/lib/kamal/commands/server.rb +5 -0
  33. data/lib/kamal/configuration/accessory.rb +9 -8
  34. data/lib/kamal/configuration/boot.rb +38 -7
  35. data/lib/kamal/configuration/docs/accessory.yml +28 -1
  36. data/lib/kamal/configuration/docs/boot.yml +17 -2
  37. data/lib/kamal/configuration/docs/configuration.yml +2 -1
  38. data/lib/kamal/configuration/docs/proxy.yml +844 -24
  39. data/lib/kamal/configuration/docs/role.yml +86 -0
  40. data/lib/kamal/configuration/loadbalancer.rb +70 -7
  41. data/lib/kamal/configuration/proxy/acme.rb +69 -0
  42. data/lib/kamal/configuration/proxy/run.rb +194 -5
  43. data/lib/kamal/configuration/proxy.rb +495 -18
  44. data/lib/kamal/configuration/role/healthcheck.rb +95 -0
  45. data/lib/kamal/configuration/role.rb +104 -1
  46. data/lib/kamal/configuration/validator/proxy.rb +623 -10
  47. data/lib/kamal/configuration/validator/role.rb +26 -0
  48. data/lib/kamal/configuration/validator.rb +26 -3
  49. data/lib/kamal/configuration.rb +197 -0
  50. data/lib/kamal/sshkit_with_ext.rb +27 -2
  51. data/lib/kamal/utils.rb +22 -2
  52. data/lib/kamal/version.rb +1 -1
  53. data/lib/kamal.rb +11 -0
  54. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b02db379375fb5102b7464401e6d1d2ca56a0209df5965cd36b87face85a4cb7
4
- data.tar.gz: 78e9cd7a40f9056abd03f39204e309a01bc4c884705c8a931d9aa5f622045863
3
+ metadata.gz: 6645870f4a0452741a5974d1d822e73fae0d4a04aec353196af3c02b3fb149d2
4
+ data.tar.gz: 23d1738ee101bf3eda8c03c363845dacc396403767271f7fbda7cd6656b9f0ff
5
5
  SHA512:
6
- metadata.gz: 3a6c6bb668d700f596f330283d1c695b6d4120c653cc9bf8ff9be9f123f78b9d0725787d3dc276f8145c9f72cbee9786faac7fe68108337c3ccc622adb9c7ed5
7
- data.tar.gz: 508b2b00b942d6f47abeb79f17a1d126b025e1b5aec08a65b9dc48088205b2967540f6877328067a64151a333a28c4d9b71cc3201fdad19555dd715d09e9e443
6
+ metadata.gz: 972ac9e339a4b56463c2f6caaf764a272cc01002101345e2696474fb01817d3a36dcb2c825b6f188521da82c764764323ff1b92b34ced280a330eb5a1d1bde0d
7
+ data.tar.gz: 7e698f1dbb093d79787f7c48aec3053742c90bfe08261b942d66a208b8051f53b8154969f796c82e29dac893b531dd9bd945a11015f598f4ac76577b271ce1fe
@@ -1,14 +1,16 @@
1
1
  class Kamal::Cli::App::Boot
2
- attr_reader :host, :role, :version, :barrier, :sshkit
2
+ attr_reader :host, :role, :version, :barrier, :sshkit, :cli
3
3
  delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
4
+ delegate :run_hook, to: :cli
4
5
  delegate :assets?, :running_proxy?, to: :role
5
6
 
6
- def initialize(host, role, sshkit, version, barrier)
7
+ def initialize(host, role, sshkit, version, barrier, cli)
7
8
  @host = host
8
9
  @role = role
9
10
  @version = version
10
11
  @barrier = barrier
11
12
  @sshkit = sshkit
13
+ @cli = cli
12
14
  end
13
15
 
14
16
  def run
@@ -54,25 +56,65 @@ class Kamal::Cli::App::Boot
54
56
  if running_proxy?
55
57
  endpoint = capture_with_info(*app.container_id_for_version(version)).strip
56
58
  raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
59
+
60
+ run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
61
+ info "Deploying #{role} on #{host} via kamal-proxy (waiting up to #{KAMAL.config.deploy_timeout}s for it to become healthy)..."
57
62
  execute *app.deploy(target: endpoint)
63
+ run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
58
64
  else
59
- Kamal::Cli::Healthcheck::Poller.wait_for_healthy { capture_with_info(*app.status(version: version)) }
65
+ Kamal::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { health_status }
60
66
  end
61
67
  rescue => e
62
68
  error "Failed to boot #{role} on #{host}"
69
+ dump_diagnostics
63
70
  raise e
64
71
  end
65
72
 
73
+ # An exec probe is docker-invisible — the container declares no healthcheck, so
74
+ # `docker inspect` would only ever report its state. Poll the probe instead.
75
+ def health_status
76
+ role.healthcheck&.exec? ? exec_probe_status : capture_with_info(*app.status(version: version))
77
+ end
78
+
79
+ def exec_probe_status
80
+ execute *app.health_probe(version: version)
81
+ "healthy"
82
+ rescue SSHKit::Command::Failed
83
+ "exec probe exited non-zero"
84
+ end
85
+
86
+ # Every failed boot gets the container log, and the health probe history when the
87
+ # container declares a healthcheck — non-primary roles have no kamal-proxy report to fall back on.
88
+ def dump_diagnostics
89
+ error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
90
+
91
+ health_log = capture_with_info(*app.container_health_log(version: version)).strip
92
+ error health_log unless health_log.empty? || health_log == "null"
93
+ rescue SSHKit::Command::Failed
94
+ error "Could not fetch logs for #{version}"
95
+ end
96
+
66
97
  def stop_new_version
67
98
  execute *app.stop(version: version), raise_on_non_zero_exit: false
68
99
  end
69
100
 
70
- def stop_old_version(version)
71
- execute *app.stop(version: version), raise_on_non_zero_exit: false
101
+ def stop_old_version(old_version)
102
+ run_stop_hook "pre-app-stop", old_version
103
+ execute *app.stop(version: old_version), raise_on_non_zero_exit: false
104
+ run_stop_hook "post-app-stop", old_version
105
+
72
106
  execute *app.clean_up_assets if assets?
73
107
  execute *app.clean_up_error_pages if KAMAL.config.error_pages_path
74
108
  end
75
109
 
110
+ # The new version is already live by the time the old one is stopped, so a failing
111
+ # drain hook must not fail the deploy — warn and stop the old container anyway.
112
+ def run_stop_hook(hook, old_version)
113
+ run_hook hook, hosts: host.to_s, role: role.name, version: old_version
114
+ rescue Kamal::Cli::HookError => e
115
+ error "#{e.message}\nContinuing anyway: #{version} is already live for #{role} on #{host}."
116
+ end
117
+
76
118
  def release_barrier
77
119
  if barrier.open
78
120
  info "First #{KAMAL.primary_role} container is healthy on #{host}, booting any other roles"
@@ -91,12 +133,6 @@ class Kamal::Cli::App::Boot
91
133
  def close_barrier
92
134
  if barrier.close
93
135
  info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
94
- begin
95
- error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
96
- error capture_with_info(*app.container_health_log(version: version))
97
- rescue SSHKit::Command::Failed
98
- error "Could not fetch logs for #{version}"
99
- end
100
136
  end
101
137
  end
102
138
 
@@ -0,0 +1,59 @@
1
+ class Kamal::Cli::App::RolloutBoot
2
+ attr_reader :host, :role, :version, :sshkit
3
+ delegate :execute, :capture_with_info, :error, :upload!, to: :sshkit
4
+
5
+ def initialize(host, role, sshkit, version)
6
+ @host = host
7
+ @role = role
8
+ @version = version
9
+ @sshkit = sshkit
10
+ end
11
+
12
+ def run
13
+ ensure_not_already_deployed
14
+
15
+ begin
16
+ start_rollout_target
17
+ rescue
18
+ stop_rollout_target
19
+ raise
20
+ end
21
+ rescue Kamal::Cli::BootError => e
22
+ error e.message
23
+ raise
24
+ end
25
+
26
+ private
27
+ def ensure_not_already_deployed
28
+ if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
29
+ raise Kamal::Cli::BootError, "Version #{version} is already deployed for #{role} on #{host}, roll out a different version"
30
+ end
31
+ end
32
+
33
+ def start_rollout_target
34
+ audit "Booted rollout target version #{version}"
35
+ hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
36
+
37
+ execute *app.ensure_env_directory
38
+ upload! role.secrets_io(host), role.secrets_path, mode: "0600"
39
+
40
+ execute *app.run(hostname: hostname)
41
+
42
+ endpoint = capture_with_info(*app.container_id_for_version(version)).strip
43
+ raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
44
+
45
+ execute *app.rollout_deploy(target: endpoint)
46
+ end
47
+
48
+ def stop_rollout_target
49
+ execute *app.stop(version: version), raise_on_non_zero_exit: false
50
+ end
51
+
52
+ def app
53
+ @app ||= KAMAL.app(role: role, host: host)
54
+ end
55
+
56
+ def audit(message)
57
+ execute *KAMAL.auditor(role: role).record(message), verbosity: :debug
58
+ end
59
+ end
@@ -9,9 +9,12 @@ class Kamal::Cli::App::SslCertificates
9
9
  end
10
10
 
11
11
  def run
12
- if role.running_proxy? && role.proxy.custom_ssl_certificate?
13
- info "Writing SSL certificates for #{role.name} on #{host}"
14
- execute *app.create_ssl_directory
12
+ return unless role.running_proxy? && (role.proxy.custom_ssl_certificate? || role.proxy.client_ca?)
13
+
14
+ info "Writing SSL certificates for #{role.name} on #{host}"
15
+ execute *app.create_ssl_directory
16
+
17
+ if role.proxy.custom_ssl_certificate?
15
18
  if cert_content = role.proxy.certificate_pem_content
16
19
  upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644")
17
20
  end
@@ -19,6 +22,12 @@ class Kamal::Cli::App::SslCertificates
19
22
  upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644")
20
23
  end
21
24
  end
25
+
26
+ # The client CA arrives the same way the server certificate does: as
27
+ # secret content named in .kamal/secrets, resolved here at upload time.
28
+ if role.proxy.client_ca?
29
+ upload!(StringIO.new(role.proxy.client_ca_pem_content), role.proxy.host_client_ca, mode: "0644")
30
+ end
22
31
  end
23
32
 
24
33
  private
data/lib/kamal/cli/app.rb CHANGED
@@ -18,17 +18,24 @@ class Kamal::Cli::App < Kamal::Cli::Base
18
18
 
19
19
  # Primary hosts and roles are returned first, so they can open the barrier
20
20
  barrier = Kamal::Cli::Healthcheck::Barrier.new
21
+ cli = self
21
22
 
22
- host_boot_groups.each do |hosts|
23
+ boot_groups = host_boot_groups
24
+
25
+ boot_groups.each_with_index do |hosts, index|
23
26
  host_list = Array(hosts).join(",")
24
27
  run_hook "pre-app-boot", hosts: host_list
25
28
 
26
- on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
27
- Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
29
+ on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
30
+ Kamal::Cli::App::Boot.new(host, role, self, version, barrier, cli).run
28
31
  end
29
32
 
30
33
  run_hook "post-app-boot", hosts: host_list
31
- sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait
34
+
35
+ # `wait` paces one group against the next, so the last group has nothing to pace
36
+ # against. Without a `limit` there is only ever one group, and waiting at all
37
+ # would be a fixed delay on every deploy buying no staggering.
38
+ sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait && index < boot_groups.size - 1
32
39
  end
33
40
 
34
41
  # Tag once the app booted on all hosts
@@ -43,7 +50,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
43
50
  desc "start", "Start existing app container on servers"
44
51
  def start
45
52
  modify(lock: true) do
46
- on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
53
+ cli = self
54
+
55
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
47
56
  app = KAMAL.app(role: role, host: host)
48
57
  execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
49
58
  execute *app.start, raise_on_non_zero_exit: false
@@ -53,7 +62,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
53
62
  endpoint = capture_with_info(*app.container_id_for_version(version)).strip
54
63
  raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
55
64
 
65
+ cli.run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
56
66
  execute *app.deploy(target: endpoint)
67
+ cli.run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
57
68
  end
58
69
  end
59
70
  end
@@ -62,7 +73,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
62
73
  desc "stop", "Stop app container on servers"
63
74
  def stop
64
75
  modify(lock: true) do
65
- on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|
76
+ on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.parallel_roles?) do |host, role|
66
77
  app = KAMAL.app(role: role, host: host)
67
78
  execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
68
79
 
@@ -272,6 +283,18 @@ class Kamal::Cli::App < Kamal::Cli::Base
272
283
  end
273
284
  end
274
285
 
286
+ desc "rollout <deploy|set|stop>", "Manage a canary rollout of a new version through kamal-proxy"
287
+ option :percent, type: :numeric, desc: "Percentage of traffic to send to the rollout target"
288
+ option :list, type: :array, desc: "Send requests whose kamal-rollout cookie matches these values to the rollout target"
289
+ def rollout(action)
290
+ case action
291
+ when "deploy" then start_rollout
292
+ when "set" then set_rollout
293
+ when "stop" then stop_rollout
294
+ else raise ArgumentError, "Unknown rollout action '#{action}', expected deploy, set or stop"
295
+ end
296
+ end
297
+
275
298
  desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
276
299
  def remove_container(version)
277
300
  modify(lock: true) do
@@ -323,6 +346,47 @@ class Kamal::Cli::App < Kamal::Cli::Base
323
346
  end
324
347
 
325
348
  private
349
+ def start_rollout
350
+ modify(lock: true) do
351
+ say "Get most recent version available as an image...", :magenta unless options[:version]
352
+ using_version(version_or_latest) do |version|
353
+ say "Start rollout target with version #{version} alongside the live version...", :magenta
354
+
355
+ on(KAMAL.proxy_hosts) do
356
+ KAMAL.roles_on(host).each do |role|
357
+ Kamal::Cli::App::Assets.new(host, role, self).run if role.running_proxy?
358
+ end
359
+ end
360
+
361
+ on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
362
+ Kamal::Cli::App::RolloutBoot.new(host, role, self, version).run if role.running_proxy?
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+ def set_rollout
369
+ percent, list = options[:percent], options[:list]
370
+
371
+ if percent.nil? && list.blank?
372
+ raise ArgumentError, "No traffic split provided. You must specify --percent or --list."
373
+ end
374
+
375
+ modify(lock: true) do
376
+ on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
377
+ execute *KAMAL.app(role: role, host: host).rollout_set(percent: percent, list: list) if role.running_proxy?
378
+ end
379
+ end
380
+ end
381
+
382
+ def stop_rollout
383
+ modify(lock: true) do
384
+ on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
385
+ execute *KAMAL.app(role: role, host: host).rollout_stop if role.running_proxy?
386
+ end
387
+ end
388
+ end
389
+
326
390
  def hosts_removing_all_roles
327
391
  KAMAL.app_hosts.select { |host| KAMAL.roles_on(host).map(&:name).sort == KAMAL.config.host_roles(host.to_s).map(&:name).sort }
328
392
  end
@@ -363,6 +427,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
363
427
  end
364
428
 
365
429
  def host_boot_groups
366
- KAMAL.config.boot.limit ? KAMAL.app_hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.app_hosts ]
430
+ hosts = KAMAL.app_hosts
431
+ limit = KAMAL.config.boot.limit_for(hosts)
432
+
433
+ limit ? hosts.each_slice(limit).to_a : [ hosts ]
367
434
  end
368
435
  end
@@ -11,6 +11,10 @@ module Kamal::Cli
11
11
  class LockHeldError < StandardError; end
12
12
  class LockMissingError < StandardError; end
13
13
 
14
+ # Hooks set process-global state (the hook env and the SSHKit verbosity), so
15
+ # serialize them: some fire from inside SSHKit's per-host threads.
16
+ HOOK_MUTEX = Mutex.new
17
+
14
18
  def self.exit_on_failure?() true end
15
19
  def self.dynamic_command_class() Kamal::Cli::Alias::Command end
16
20
 
@@ -44,6 +48,43 @@ module Kamal::Cli
44
48
  initialize_commander unless KAMAL.configured?
45
49
  end
46
50
 
51
+ # Public so collaborators like Kamal::Cli::App::Boot can fire hooks, but not a Thor command.
52
+ no_commands do
53
+ def run_hook(hook, **extra_details)
54
+ if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
55
+ details = {
56
+ hosts: KAMAL.hosts.join(","),
57
+ roles: KAMAL.specific_roles&.join(","),
58
+ lock: KAMAL.holding_lock?.to_s,
59
+ command: command,
60
+ subcommand: subcommand
61
+ }.compact
62
+
63
+ hooks_output = KAMAL.config.hooks_output_for(hook)
64
+
65
+ # CLI flags override config: -q hides all, -v shows all
66
+ # Config setting :verbose forces output, :quiet forces silence
67
+ hook_verbosity = if KAMAL.verbosity == :info && hooks_output
68
+ VERBOSITY.fetch(hooks_output)
69
+ else
70
+ KAMAL.verbosity
71
+ end
72
+
73
+ HOOK_MUTEX.synchronize do
74
+ with_env KAMAL.hook.env(**details, **extra_details) do
75
+ KAMAL.with_verbosity(hook_verbosity) do
76
+ run_locally do
77
+ execute *KAMAL.hook.run(hook)
78
+ end
79
+ end
80
+ rescue SSHKit::Command::Failed => e
81
+ raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+
47
88
  private
48
89
  def options_with_subcommand_class_options
49
90
  options.merge(@_initializer.last[:class_options] || {})
@@ -221,38 +262,6 @@ module Kamal::Cli
221
262
  raise
222
263
  end
223
264
 
224
- def run_hook(hook, **extra_details)
225
- if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
226
- details = {
227
- hosts: KAMAL.hosts.join(","),
228
- roles: KAMAL.specific_roles&.join(","),
229
- lock: KAMAL.holding_lock?.to_s,
230
- command: command,
231
- subcommand: subcommand
232
- }.compact
233
-
234
- hooks_output = KAMAL.config.hooks_output_for(hook)
235
-
236
- # CLI flags override config: -q hides all, -v shows all
237
- # Config setting :verbose forces output, :quiet forces silence
238
- hook_verbosity = if KAMAL.verbosity == :info && hooks_output
239
- VERBOSITY.fetch(hooks_output)
240
- else
241
- KAMAL.verbosity
242
- end
243
-
244
- with_env KAMAL.hook.env(**details, **extra_details) do
245
- KAMAL.with_verbosity(hook_verbosity) do
246
- run_locally do
247
- execute *KAMAL.hook.run(hook)
248
- end
249
- end
250
- rescue SSHKit::Command::Failed => e
251
- raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
252
- end
253
- end
254
- end
255
-
256
265
  def on(*args, &block)
257
266
  pre_connect_if_required
258
267
 
@@ -0,0 +1,28 @@
1
+ # Checks that only read the configuration — no SSH, no network. They report problems
2
+ # that are visible in deploy.yml alone, so they still run when every host is unreachable.
3
+ class Kamal::Cli::Doctor::ConfigChecks
4
+ def run
5
+ readiness_results
6
+ end
7
+
8
+ private
9
+ def result(check, target, status, detail)
10
+ Kamal::Cli::Doctor::Result.new(check, target, status, detail)
11
+ end
12
+
13
+ def readiness_results
14
+ KAMAL.roles.map { |role| readiness_check(role) }
15
+ end
16
+
17
+ def readiness_check(role)
18
+ if role.readiness_source != :none
19
+ result :readiness, role.name, :ok, role.readiness_description
20
+ elsif role.readiness_gated?
21
+ # readiness_gated? with no source left is `healthcheck: false` — the gap is deliberate.
22
+ result :readiness, role.name, :ok, "healthcheck: false — accepted #{role.readiness_delay}s after the container starts"
23
+ else
24
+ result :readiness, role.name, :warn, "no healthcheck — the old container stops #{role.readiness_delay}s after the new one starts; " \
25
+ "add a `healthcheck:` block, or opt out with `healthcheck: false`"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,114 @@
1
+ require "resolv"
2
+ require "socket"
3
+ require "openssl"
4
+
5
+ # Local (no SSH) readiness checks for the domains kamal-proxy will serve:
6
+ # DNS resolution against the configured hosts and TLS certificate expiry.
7
+ class Kamal::Cli::Doctor::EndpointChecks
8
+ CERTIFICATE_EXPIRY_WARN_DAYS = 14
9
+ SECONDS_PER_DAY = 86_400
10
+ TLS_CONNECT_TIMEOUT = 5
11
+
12
+ def run
13
+ dns_results + certificate_results
14
+ end
15
+
16
+ private
17
+ def result(check, target, status, detail)
18
+ Kamal::Cli::Doctor::Result.new(check, target, status, detail)
19
+ end
20
+
21
+ # Roles and accessories that run behind the proxy with custom domains.
22
+ def proxied_units
23
+ @proxied_units ||= (KAMAL.roles + KAMAL.config.proxy_accessories).select do |unit|
24
+ unit.running_proxy? && unit.proxy.hosts.any?
25
+ end
26
+ end
27
+
28
+ def dns_results
29
+ proxied_units.flat_map do |unit|
30
+ unit.proxy.hosts.map { |domain| dns_check(domain, unit.hosts) }
31
+ end
32
+ end
33
+
34
+ def certificate_results
35
+ proxied_units.flat_map do |unit|
36
+ next [] unless unit.proxy.ssl?
37
+
38
+ unit.proxy.hosts.map do |domain|
39
+ unit.proxy.custom_ssl_certificate? ? custom_certificate_check(unit.proxy, domain) : served_certificate_check(domain)
40
+ end
41
+ end
42
+ end
43
+
44
+ def dns_check(domain, unit_hosts)
45
+ resolved = Resolv.getaddresses(domain)
46
+ expected = expected_ips(unit_hosts)
47
+ matching = resolved & expected
48
+
49
+ if resolved.empty?
50
+ result :dns, domain, :fail, "does not resolve"
51
+ elsif matching.any?
52
+ result :dns, domain, :ok, "resolves to #{matching.join(", ")}"
53
+ else
54
+ result :dns, domain, :warn, "resolves to #{resolved.join(", ")}, expected one of #{expected.join(", ")} (fine when fronted by a CDN or load balancer)"
55
+ end
56
+ rescue StandardError => e
57
+ result :dns, domain, :fail, "could not resolve (#{e.message})"
58
+ end
59
+
60
+ def expected_ips(unit_hosts)
61
+ unit_hosts.flat_map do |unit_host|
62
+ if unit_host =~ Resolv::IPv4::Regex || unit_host =~ Resolv::IPv6::Regex
63
+ [ unit_host ]
64
+ else
65
+ Resolv.getaddresses(unit_host)
66
+ end
67
+ end.uniq
68
+ end
69
+
70
+ def custom_certificate_check(proxy, domain)
71
+ pem = proxy.certificate_pem_content
72
+ return result(:certificate, domain, :fail, "the certificate_pem secret is missing or empty") if pem.blank?
73
+
74
+ expiry_check domain, OpenSSL::X509::Certificate.new(pem), source: "configured certificate"
75
+ rescue OpenSSL::X509::CertificateError => e
76
+ result :certificate, domain, :fail, "certificate_pem is not a valid certificate (#{e.message})"
77
+ rescue StandardError => e
78
+ result :certificate, domain, :fail, "could not check the configured certificate (#{e.message})"
79
+ end
80
+
81
+ def served_certificate_check(domain)
82
+ expiry_check domain, peer_certificate(domain), source: "served certificate"
83
+ rescue StandardError => e
84
+ result :certificate, domain, :warn, "could not check TLS on #{domain}:443 (#{e.message}) - expected if the app isn't deployed yet"
85
+ end
86
+
87
+ def expiry_check(domain, certificate, source:)
88
+ days_left = ((certificate.not_after - Time.now) / SECONDS_PER_DAY).floor
89
+
90
+ if days_left.negative?
91
+ result :certificate, domain, :fail, "#{source} expired on #{certificate.not_after}"
92
+ elsif days_left < CERTIFICATE_EXPIRY_WARN_DAYS
93
+ result :certificate, domain, :warn, "#{source} expires in #{days_left} days (#{certificate.not_after})"
94
+ else
95
+ result :certificate, domain, :ok, "#{source} valid until #{certificate.not_after}"
96
+ end
97
+ end
98
+
99
+ def peer_certificate(domain, port = 443)
100
+ Socket.tcp(domain, port, connect_timeout: TLS_CONNECT_TIMEOUT) do |tcp|
101
+ context = OpenSSL::SSL::SSLContext.new
102
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE # only inspecting expiry, so self-signed is fine
103
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
104
+ ssl.hostname = domain
105
+
106
+ begin
107
+ ssl.connect
108
+ ssl.peer_cert
109
+ ensure
110
+ ssl.close
111
+ end
112
+ end
113
+ end
114
+ end