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
@@ -0,0 +1,178 @@
1
+ # Remote readiness checks for a single host, run inside an SSHKit backend.
2
+ #
3
+ # Every check rescues StandardError at its boundary: the doctor's contract is
4
+ # to report broken environments, not crash on them, and a failed connection can
5
+ # raise anything from Net::SSH errors to Errno and DNS resolution errors.
6
+ class Kamal::Cli::Doctor::HostChecks
7
+ attr_reader :host, :sshkit, :proxy_host
8
+ delegate :execute, :capture_with_info, to: :sshkit
9
+
10
+ def initialize(host, sshkit, proxy_host:)
11
+ @host = host
12
+ @sshkit = sshkit
13
+ @proxy_host = proxy_host
14
+ end
15
+
16
+ def run
17
+ checks = { ssh: ssh_check }
18
+
19
+ if checks[:ssh].ok?
20
+ checks[:docker] = docker_check
21
+ checks[:registry] = registry_check
22
+ checks.merge!(proxy_checks) if proxy_host
23
+ end
24
+
25
+ checks
26
+ end
27
+
28
+ private
29
+ def result(check, status, detail)
30
+ Kamal::Cli::Doctor::Result.new(check, host, status, detail)
31
+ end
32
+
33
+ def ssh_check
34
+ execute "true"
35
+ result :ssh, :ok, "connected"
36
+ rescue StandardError => e
37
+ result :ssh, :fail, "#{e.class}: #{e.message}"
38
+ end
39
+
40
+ def docker_check
41
+ if execute(*KAMAL.docker.running?, raise_on_non_zero_exit: false)
42
+ result :docker, :ok, "docker is installed and running"
43
+ else
44
+ result :docker, :fail, "docker is not installed or not running (run `kamal server bootstrap`)"
45
+ end
46
+ rescue StandardError => e
47
+ result :docker, :fail, "#{e.class}: #{e.message}"
48
+ end
49
+
50
+ def registry_check
51
+ return result(:registry, :ok, "local registry, no login required") if KAMAL.registry.local?
52
+
53
+ if execute(*KAMAL.registry.login, raise_on_non_zero_exit: false)
54
+ result :registry, :ok, "logged in to #{registry_name}"
55
+ else
56
+ result :registry, :fail, "docker login to #{registry_name} failed (check registry credentials)"
57
+ end
58
+ rescue StandardError => e
59
+ result :registry, :fail, "#{e.class}: #{e.message}"
60
+ end
61
+
62
+ def proxy_checks
63
+ version, version_error = capture_proxy_version
64
+
65
+ {
66
+ proxy_image: proxy_image_check,
67
+ proxy_version: proxy_version_check(version, version_error),
68
+ proxy_socket: proxy_socket_check(proxy_running: version.present?),
69
+ ports: ports_check(proxy_running: version.present?)
70
+ }
71
+ end
72
+
73
+ def capture_proxy_version
74
+ [ capture_with_info(*KAMAL.proxy(host).version).strip.presence, nil ]
75
+ rescue StandardError => e
76
+ [ nil, e ]
77
+ end
78
+
79
+ def proxy_image_check
80
+ image = expected_proxy_image
81
+
82
+ if execute(*KAMAL.docker.manifest_available?(image), raise_on_non_zero_exit: false)
83
+ result :proxy_image, :ok, "#{image} manifest is fetchable"
84
+ else
85
+ result :proxy_image, :fail, "cannot fetch the manifest for #{image} (registry unreachable or unauthorized)"
86
+ end
87
+ rescue StandardError => e
88
+ result :proxy_image, :fail, "#{e.class}: #{e.message}"
89
+ end
90
+
91
+ def proxy_version_check(version, error)
92
+ minimum = Kamal::Configuration::Proxy::Run::MINIMUM_VERSION
93
+
94
+ if error
95
+ result :proxy_version, :warn, "could not determine the running version (#{error.message})"
96
+ elsif version.nil?
97
+ result :proxy_version, :ok, "not running (will be started on deploy)"
98
+ elsif Kamal::Utils.older_version?(version, minimum)
99
+ result :proxy_version, :fail, "#{version} is older than the minimum #{minimum}, run `kamal proxy reboot` to update"
100
+ else
101
+ result :proxy_version, :ok, "#{version} (minimum #{minimum})"
102
+ end
103
+ rescue ArgumentError
104
+ result :proxy_version, :warn, "running image tag #{version} is not a version number"
105
+ end
106
+
107
+ # What a container path has to look like to count as a container runtime
108
+ # socket when the config no longer names one.
109
+ DOCKER_SOCKET_PATTERN = %r{docker\.sock\z}
110
+
111
+ # The config-time sleep/docker_socket pairing check covers the *current*
112
+ # config; the running container keeps whatever it was booted with. A proxy
113
+ # from before the socket was added silently lacks the mount - the failure
114
+ # mode is one hung request when a sleeping service never wakes - and one
115
+ # from before it was removed keeps root-equivalent host access.
116
+ def proxy_socket_check(proxy_running:)
117
+ expected = KAMAL.config.proxy_run(host)&.docker_socket
118
+
119
+ unless proxy_running
120
+ detail = expected ? "docker socket #{expected} will be mounted on boot" : "no docker socket configured"
121
+ return result(:proxy_socket, :ok, detail)
122
+ end
123
+
124
+ mounted = capture_with_info(*KAMAL.proxy(host).mount_destinations, raise_on_non_zero_exit: false).split("\n").map(&:strip)
125
+
126
+ if expected
127
+ if mounted.include?(expected)
128
+ result :proxy_socket, :ok, "docker socket #{expected} is mounted"
129
+ elsif sleep_configured?
130
+ result :proxy_socket, :fail, "the running kamal-proxy has no #{expected} mount, so sleeping services never wake - run `kamal proxy reboot`"
131
+ else
132
+ # Nothing sleeps yet, so nothing hangs - drift rather than breakage.
133
+ result :proxy_socket, :warn, "the running kamal-proxy has no #{expected} mount - run `kamal proxy reboot` to apply the current configuration"
134
+ end
135
+ elsif (stray = mounted.grep(DOCKER_SOCKET_PATTERN).first)
136
+ result :proxy_socket, :warn, "the running kamal-proxy mounts #{stray} but the config no longer asks for it - " \
137
+ "the socket is root-equivalent host access; `kamal proxy reboot` removes it"
138
+ else
139
+ result :proxy_socket, :ok, "no docker socket configured or mounted"
140
+ end
141
+ rescue StandardError => e
142
+ result :proxy_socket, :warn, "could not check the docker socket (#{e.message})"
143
+ end
144
+
145
+ def sleep_configured?
146
+ KAMAL.config.roles.any? { |role| role.running_proxy? && role.proxy.proxy_config["sleep"].present? }
147
+ end
148
+
149
+ def ports_check(proxy_running:)
150
+ run_config = KAMAL.config.proxy_run(host)
151
+ return result(:ports, :ok, "proxy ports are not published, nothing to check") if run_config && !run_config.publish?
152
+
153
+ http_port = run_config&.http_port || Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT
154
+ https_port = run_config&.https_port || Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT
155
+
156
+ if proxy_running
157
+ result :ports, :ok, "ports #{http_port}/#{https_port} held by the running kamal-proxy"
158
+ elsif (busy = busy_ports(http_port, https_port)).any?
159
+ result :ports, :fail, "port(s) #{busy.join(", ")} already in use by another process"
160
+ else
161
+ result :ports, :ok, "ports #{http_port}/#{https_port} free"
162
+ end
163
+ rescue StandardError => e
164
+ result :ports, :warn, "could not check ports (#{e.message})"
165
+ end
166
+
167
+ def busy_ports(*ports)
168
+ ports.select { |port| capture_with_info(*KAMAL.server.listeners_on(port), raise_on_non_zero_exit: false).present? }
169
+ end
170
+
171
+ def expected_proxy_image
172
+ KAMAL.config.proxy_run(host)&.image || "#{KAMAL.config.proxy_boot.image_default}:#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}"
173
+ end
174
+
175
+ def registry_name
176
+ KAMAL.config.registry.server || "Docker Hub"
177
+ end
178
+ end
@@ -0,0 +1,112 @@
1
+ require "kamal/sshkit_with_ext"
2
+
3
+ # Runs read-only deploy readiness checks and collects the results, without ever
4
+ # raising on a broken environment - failures become failing results instead.
5
+ class Kamal::Cli::Doctor
6
+ include SSHKit::DSL
7
+
8
+ HOST_CHECKS = %i[ ssh docker registry proxy_image proxy_version proxy_socket ports ]
9
+
10
+ CHECK_TITLES = {
11
+ ssh: "SSH",
12
+ docker: "Docker",
13
+ registry: "Registry",
14
+ proxy_image: "Proxy image",
15
+ proxy_version: "Proxy version",
16
+ proxy_socket: "Proxy docker socket",
17
+ ports: "Ports",
18
+ dns: "DNS",
19
+ certificate: "Certificates",
20
+ readiness: "Readiness"
21
+ }.freeze
22
+
23
+ STATUS_COLORS = { ok: :green, warn: :yellow, fail: :red }.freeze
24
+
25
+ Result = Struct.new(:check, :target, :status, :detail) do
26
+ def ok?
27
+ status == :ok
28
+ end
29
+
30
+ def warn?
31
+ status == :warn
32
+ end
33
+
34
+ def fail?
35
+ status == :fail
36
+ end
37
+
38
+ def title
39
+ CHECK_TITLES[check]
40
+ end
41
+
42
+ def to_s
43
+ "#{status.to_s.upcase} #{target}: #{detail}"
44
+ end
45
+ end
46
+
47
+ attr_reader :results
48
+
49
+ def initialize
50
+ @results = []
51
+ end
52
+
53
+ def run
54
+ @results = host_check_results + endpoint_check_results + config_check_results
55
+ end
56
+
57
+ def failures
58
+ results.select(&:fail?)
59
+ end
60
+
61
+ def warnings
62
+ results.select(&:warn?)
63
+ end
64
+
65
+ def successful?
66
+ failures.none?
67
+ end
68
+
69
+ private
70
+ def host_check_results
71
+ results_by_host = collect_host_checks
72
+
73
+ HOST_CHECKS.flat_map do |check|
74
+ KAMAL.hosts.filter_map { |host| results_by_host.dig(host, check) }
75
+ end
76
+ end
77
+
78
+ def collect_host_checks
79
+ results_by_host = {}
80
+ mutex = Mutex.new
81
+ proxy_hosts = KAMAL.proxy_hosts
82
+ error = nil
83
+
84
+ begin
85
+ on(KAMAL.hosts) do |host|
86
+ checks = Kamal::Cli::Doctor::HostChecks.new(host.hostname, self, proxy_host: proxy_hosts.include?(host.hostname)).run
87
+ mutex.synchronize { results_by_host[host.hostname] = checks }
88
+ end
89
+ # Only ExecuteError: sshkit 1.25 has no MultipleExecuteError, and naming
90
+ # a constant that does not exist turns "a host's checks failed" into a
91
+ # NameError - the opposite of the doctor's never-crash contract.
92
+ rescue SSHKit::Runner::ExecuteError => e
93
+ # Per-check errors are captured inside HostChecks; getting here means a
94
+ # host's checks never completed at all. Record those as SSH failures below.
95
+ error = e
96
+ end
97
+
98
+ KAMAL.hosts.each do |host|
99
+ results_by_host[host] ||= { ssh: Result.new(:ssh, host, :fail, "could not run checks (#{error&.message || "connection failed"})") }
100
+ end
101
+
102
+ results_by_host
103
+ end
104
+
105
+ def endpoint_check_results
106
+ Kamal::Cli::Doctor::EndpointChecks.new.run
107
+ end
108
+
109
+ def config_check_results
110
+ Kamal::Cli::Doctor::ConfigChecks.new.run
111
+ end
112
+ end
@@ -0,0 +1,7 @@
1
+ # Raised when the config declares a healthcheck the running container does not have.
2
+ #
3
+ # Deliberately NOT a Kamal::Cli::Healthcheck::Error: the poller retries that class until
4
+ # deploy_timeout, and drift is deterministic — `.State.Health` exists from the moment a
5
+ # container with a healthcheck is created, so retrying only burns the timeout before
6
+ # reporting the same thing.
7
+ class Kamal::Cli::Healthcheck::DriftError < StandardError; end
@@ -1,30 +1,41 @@
1
1
  module Kamal::Cli::Healthcheck::Poller
2
2
  extend self
3
3
 
4
- def wait_for_healthy(&block)
4
+ NO_HEALTHCHECK = Kamal::Commands::Base::NO_HEALTHCHECK
5
+
6
+ def wait_for_healthy(role:, &block)
5
7
  attempt = 1
6
8
  timeout_at = Time.now + KAMAL.config.deploy_timeout
7
- readiness_delay = KAMAL.config.readiness_delay
9
+ readiness_delay = role.readiness_delay
8
10
 
9
11
  begin
10
12
  status = block.call
11
13
 
12
- if status == "running"
13
- # Wait for the readiness delay and confirm it is still running
14
- if readiness_delay > 0
15
- info "Container is running, waiting for readiness delay of #{readiness_delay} seconds"
16
- sleep readiness_delay
17
- status = block.call
14
+ if unchecked?(status)
15
+ ensure_no_healthcheck_drift(role, status)
16
+
17
+ if docker_state(status) == "running"
18
+ announce_missing_gate(role, readiness_delay)
19
+
20
+ # Wait for the readiness delay and confirm it is still running
21
+ if readiness_delay > 0
22
+ sleep readiness_delay
23
+ status = block.call
24
+ ensure_no_healthcheck_drift(role, status)
25
+ end
18
26
  end
19
27
  end
20
28
 
21
- unless %w[ running healthy ].include?(status)
29
+ unless acceptable?(status)
22
30
  raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})"
23
31
  end
24
32
  rescue Kamal::Cli::Healthcheck::Error => e
25
33
  time_left = timeout_at - Time.now
26
34
  if time_left > 0
27
- sleep [ attempt, time_left ].min
35
+ sleep_for = [ attempt, time_left ].min
36
+ elapsed = KAMAL.config.deploy_timeout - time_left
37
+ info "Container not ready yet, retrying in #{sleep_for.ceil}s (#{elapsed.round}s elapsed, #{time_left.round}s left)"
38
+ sleep sleep_for
28
39
  attempt += 1
29
40
  retry
30
41
  else
@@ -36,7 +47,46 @@ module Kamal::Cli::Healthcheck::Poller
36
47
  end
37
48
 
38
49
  private
50
+ def unchecked?(status)
51
+ status.to_s.start_with?("#{NO_HEALTHCHECK}:")
52
+ end
53
+
54
+ def docker_state(status)
55
+ status.to_s.delete_prefix("#{NO_HEALTHCHECK}:")
56
+ end
57
+
58
+ def acceptable?(status)
59
+ status == "healthy" || (unchecked?(status) && docker_state(status) == "running")
60
+ end
61
+
62
+ # The config asked docker to probe this container and docker is not probing it — the flags
63
+ # never reached `docker run`. Accepting it would let the deploy pass without ever checking
64
+ # readiness, which is exactly the failure the healthcheck was added to prevent.
65
+ def ensure_no_healthcheck_drift(role, status)
66
+ return unless %i[ healthcheck docker_options ].include?(role.readiness_source)
67
+
68
+ raise Kamal::Cli::Healthcheck::DriftError,
69
+ "#{role.name} declares a healthcheck but the container reports none (#{status}) — the flags never reached docker. " \
70
+ "Compare `servers/#{role.name}` in your deploy config against `docker inspect --format '{{json .Config.Healthcheck}}'` " \
71
+ "on the container; the deploy would otherwise be accepted without ever probing it."
72
+ end
73
+
74
+ # A role nobody made a readiness decision for still boots — the config-time warning owns that
75
+ # policy — but it must not do so silently, because the delay is the only gate there is.
76
+ def announce_missing_gate(role, readiness_delay)
77
+ if role.readiness_gated?
78
+ info "Container is running, waiting for readiness delay of #{readiness_delay} seconds"
79
+ else
80
+ warn "#{role.name} has no healthcheck — accepting the container as ready after the #{readiness_delay}s readiness delay alone. " \
81
+ "Add a `healthcheck:` block to gate the deploy on readiness, or `healthcheck: false` to accept this explicitly."
82
+ end
83
+ end
84
+
39
85
  def info(message)
40
86
  SSHKit.config.output.info(message)
41
87
  end
88
+
89
+ def warn(message)
90
+ SSHKit.config.output.warn(message)
91
+ end
42
92
  end
@@ -23,6 +23,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
23
23
  runtime = print_runtime do
24
24
  invoke_options = deploy_options
25
25
 
26
+ print_config_banner
27
+
28
+ say "Validate configuration and secrets...", :magenta
29
+ KAMAL.config.validate_secrets!(include_accessories: boot_accessories)
30
+
26
31
  if options[:skip_push]
27
32
  say "Pull app image...", :magenta
28
33
  invoke "kamal:cli:build:pull", [], invoke_options
@@ -66,6 +71,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
66
71
  runtime = print_runtime do
67
72
  invoke_options = deploy_options
68
73
 
74
+ print_config_banner
75
+
76
+ say "Validate configuration and secrets...", :magenta
77
+ KAMAL.config.validate_secrets!
78
+
69
79
  if options[:skip_push]
70
80
  say "Pull app image...", :magenta
71
81
  invoke "kamal:cli:build:pull", [], invoke_options
@@ -153,6 +163,23 @@ class Kamal::Cli::Main < Kamal::Cli::Base
153
163
  puts "No documentation found for #{section}"
154
164
  end
155
165
 
166
+ desc "doctor", "Diagnose deploy readiness of servers, registry, proxy, ports, DNS, certificates, and per-role readiness gates"
167
+ def doctor
168
+ say "Running readiness checks...", :magenta
169
+ pre_connect_if_required
170
+
171
+ doctor = Kamal::Cli::Doctor.new
172
+ doctor.run
173
+
174
+ print_doctor_report doctor.results
175
+
176
+ if doctor.successful?
177
+ say doctor_summary(doctor), :green
178
+ else
179
+ raise Kamal::Cli::DoctorError, doctor_failure_message(doctor)
180
+ end
181
+ end
182
+
156
183
  desc "init", "Create config stub in config/deploy.yml and secrets stub in .kamal"
157
184
  option :bundle, type: :boolean, default: false, desc: "Add Kamal to the Gemfile and create a bin/kamal binstub"
158
185
  def init
@@ -296,4 +323,40 @@ class Kamal::Cli::Main < Kamal::Cli::Base
296
323
  base_options = base_options.except("no_cache") unless base_options["no_cache"]
297
324
  { "version" => KAMAL.config.version }.merge(base_options)
298
325
  end
326
+
327
+ def print_doctor_report(results)
328
+ results.group_by(&:title).each do |title, rows|
329
+ say title
330
+ rows.each { |row| say " #{row}", Kamal::Cli::Doctor::STATUS_COLORS[row.status] }
331
+ end
332
+ end
333
+
334
+ def doctor_summary(doctor)
335
+ if doctor.warnings.any?
336
+ "Looks ready to deploy, with #{doctor.warnings.count} warning(s) to review"
337
+ else
338
+ "Everything looks ready to deploy"
339
+ end
340
+ end
341
+
342
+ def doctor_failure_message(doctor)
343
+ failing = doctor.failures.map { |failure| " #{failure.title} - #{failure}" }.join("\n")
344
+ "Found #{doctor.failures.count} failing check(s):\n#{failing}"
345
+ end
346
+
347
+ def print_config_banner
348
+ config = KAMAL.config
349
+
350
+ say "Deploying #{config.service}#{" to #{config.destination}" if config.destination} (version #{config.abbreviated_version})", :magenta
351
+ config.roles.each do |role|
352
+ hosts = "#{role.hosts.count} #{"host".pluralize(role.hosts.count)} (#{role.hosts.join(", ")})"
353
+ say " #{role.name}: #{hosts} — readiness: #{role.readiness_description}", (:yellow if role.readiness_source == :none)
354
+ end
355
+ say " proxy: #{config.proxy_hosts.join(", ")}" if config.proxy_hosts.any?
356
+ if config.proxy.load_balancing?
357
+ reason = " (auto-enabled: primary role #{config.primary_role.name} has #{config.primary_role.hosts.count} hosts)" unless config.proxy.loadbalancer.present?
358
+ say " loadbalancer: #{config.proxy.effective_loadbalancer}#{reason}"
359
+ end
360
+ say " timeouts: deploy #{config.deploy_timeout}s, drain #{config.drain_timeout}s, readiness delay #{config.readiness_delay}s"
361
+ end
299
362
  end
@@ -0,0 +1,39 @@
1
+ class Kamal::Cli::Proxy::Drift
2
+ attr_reader :host, :sshkit
3
+ delegate :capture_with_info, to: :sshkit
4
+
5
+ def initialize(host, sshkit)
6
+ @host = host
7
+ @sshkit = sshkit
8
+ end
9
+
10
+ def container_exists?
11
+ capture_with_info(*proxy.container_id, raise_on_non_zero_exit: false).strip.present?
12
+ end
13
+
14
+ # A proxy container has drifted when it was started with a different config
15
+ # digest than the one the current configuration produces. Containers booted
16
+ # by older kamal versions carry no digest label and count as drifted, so
17
+ # they converge on the first deploy after upgrading.
18
+ def drifted?
19
+ return @drifted if defined?(@drifted)
20
+ @drifted = container_exists? && current_digest != expected_digest
21
+ end
22
+
23
+ def expected_digest
24
+ @expected_digest ||= if proxy.proxy_run_config
25
+ proxy.proxy_run_config.config_digest
26
+ else
27
+ Kamal::Configuration::Proxy::Run.digest(capture_with_info(*proxy.boot_config).strip)
28
+ end
29
+ end
30
+
31
+ private
32
+ def current_digest
33
+ capture_with_info(*proxy.config_digest, raise_on_non_zero_exit: false).strip
34
+ end
35
+
36
+ def proxy
37
+ @proxy ||= KAMAL.proxy(host)
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ # Ownership bookkeeping for a load balancer host that more than one kamal app
2
+ # may be deploying to. Kamal only ever sees one deploy.yml at a time, so the
3
+ # other apps are visible solely through the files they left on the host.
4
+ class Kamal::Cli::Proxy::LoadbalancerClaim
5
+ attr_reader :host, :sshkit
6
+ delegate :capture_with_info, :execute, :upload!, :warn, to: :sshkit
7
+
8
+ def initialize(host, sshkit)
9
+ @host = host
10
+ @sshkit = sshkit
11
+ end
12
+
13
+ # The load balancer registers services under the bare service name, so a
14
+ # second app - or a second destination of this app - deploying the same name
15
+ # would silently take over its routes. Claim the name on first deploy and fail
16
+ # loudly rather than quietly stealing it.
17
+ def claim_service
18
+ owner = capture_with_info(*commands.read_service_owner, raise_on_non_zero_exit: false).strip
19
+
20
+ if owner.present? && owner != config.owner_token
21
+ raise "Service '#{config.config.service}' is already registered on the load balancer at #{host} by #{repository_in(owner)}. " \
22
+ "Deploying would take over its routes - rename this app's service or use a different load balancer."
23
+ end
24
+
25
+ execute *commands.ensure_services_directory
26
+ upload! StringIO.new(config.owner_token), config.service_owner_file
27
+ end
28
+
29
+ # Every app sharing a load balancer boots the same container, so their
30
+ # proxy/run configurations have to agree. A mismatch owned by another app is
31
+ # an error; a mismatch owned by this app is ordinary drift, reported the same
32
+ # way the per-host proxy reports it and fixed by `kamal proxy reboot`.
33
+ def claim_run_config(replace: false)
34
+ record = capture_with_info(*commands.read_run_config_record, raise_on_non_zero_exit: false).strip
35
+
36
+ if record.present?
37
+ *owner_parts, digest = record.split(" ")
38
+ owner = owner_parts.join(" ")
39
+
40
+ if digest != config.run_config_digest
41
+ if owner != config.owner_token
42
+ raise "The load balancer on #{host} was booted by #{repository_in(owner)} with a different proxy/run configuration. " \
43
+ "Apps sharing a load balancer must agree on proxy/run - align the configurations or use a different load balancer host."
44
+ elsif !replace
45
+ warn "The load balancer on #{host} is running with a configuration that no longer matches the deploy config - " \
46
+ "run `kamal proxy reboot` to apply the new configuration."
47
+ return
48
+ end
49
+ end
50
+ end
51
+
52
+ execute *commands.ensure_directory
53
+ upload! StringIO.new(config.run_config_record), config.run_config_file
54
+ end
55
+
56
+ private
57
+ def config
58
+ KAMAL.loadbalancer_config
59
+ end
60
+
61
+ def commands
62
+ KAMAL.loadbalancer
63
+ end
64
+
65
+ # Owner tokens are "<service-and-destination> <repository>"; the repository
66
+ # is the half an operator recognises as "the other app".
67
+ def repository_in(owner)
68
+ owner.split(" ").last
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ # Replaces the loadbalancer container with one booted from the current
2
+ # configuration. Shared by `kamal proxy reboot` and the drift-detected reboot
3
+ # on boot - the same sequence either way, so the two paths cannot diverge.
4
+ #
5
+ # Unlike the proxy hosts' port-holder handoff this is stop -> run: the
6
+ # loadbalancer keeps its service state in the config volume, which the
7
+ # replacement container re-mounts, so every app's routes survive the gap.
8
+ class Kamal::Cli::Proxy::LoadbalancerReboot
9
+ attr_reader :host, :sshkit
10
+ delegate :execute, :capture_with_info, :info, :upload!, to: :sshkit
11
+
12
+ def initialize(host, sshkit)
13
+ @host = host
14
+ @sshkit = sshkit
15
+ end
16
+
17
+ def run
18
+ execute *KAMAL.auditor.record("Rebooted loadbalancer"), verbosity: :debug
19
+ execute *KAMAL.registry.login
20
+
21
+ info "Stopping and removing #{KAMAL.loadbalancer.container_name} on #{host}, if running..."
22
+ execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
23
+ execute *KAMAL.loadbalancer.remove_container
24
+
25
+ if (lb_run = KAMAL.loadbalancer_config.run).secrets?
26
+ execute *KAMAL.loadbalancer.ensure_proxy_directory
27
+ upload! lb_run.secrets_io, lb_run.secrets_path, mode: "0600"
28
+ else
29
+ execute *KAMAL.loadbalancer.remove_proxy_secrets_file, raise_on_non_zero_exit: false
30
+ end
31
+
32
+ execute *KAMAL.loadbalancer.ensure_apps_config_directory
33
+ Kamal::Cli::Proxy::LoadbalancerClaim.new(host, sshkit).claim_run_config(replace: true)
34
+ execute *KAMAL.loadbalancer.run
35
+
36
+ # kamal-proxy keeps its service state in the config volume, which the
37
+ # replacement container re-mounts - every app's routes survive.
38
+ services = capture_with_info(*KAMAL.loadbalancer.list).strip
39
+ info "Services registered on the load balancer at #{host} after reboot:\n#{services}"
40
+ end
41
+ end