dash 2.12.0 → 3.0.1

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 +87 -0
  17. data/lib/kamal/cli/proxy/reboot.rb +173 -0
  18. data/lib/kamal/cli/proxy.rb +201 -34
  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 +111 -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 +81 -6
  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,173 @@
1
+ class Kamal::Cli::Proxy::Reboot
2
+ READY_TIMEOUT = 30
3
+
4
+ attr_reader :host, :sshkit
5
+ delegate :execute, :capture_with_info, :info, :upload!, to: :sshkit
6
+
7
+ def initialize(host, sshkit)
8
+ @host = host
9
+ @sshkit = sshkit
10
+ end
11
+
12
+ def run
13
+ execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
14
+ execute *KAMAL.registry.login
15
+
16
+ pull_image
17
+ replace_container
18
+ wait_until_ready
19
+ verify_services re_register_services
20
+ end
21
+
22
+ private
23
+ def proxy
24
+ @proxy ||= KAMAL.proxy(host)
25
+ end
26
+
27
+ def drift
28
+ @drift ||= Kamal::Cli::Proxy::Drift.new(host, sshkit)
29
+ end
30
+
31
+ # Pull before stopping the old container: a registry outage or rate limit
32
+ # can then no longer strand the host with no proxy at all.
33
+ def pull_image
34
+ info "Pulling kamal-proxy image on #{host}..."
35
+ execute *proxy.pull
36
+ end
37
+
38
+ def replace_container
39
+ execute *proxy.ensure_proxy_directory
40
+ execute *proxy.ensure_apps_config_directory
41
+
42
+ if proxy.port_holder?
43
+ replace_generation
44
+ else
45
+ stop_and_replace
46
+ end
47
+ end
48
+
49
+ # Phase 1 replacement: stop the old container, start the new one. Brief gap.
50
+ def stop_and_replace
51
+ info "Stopping and removing kamal-proxy on #{host}, if running..."
52
+ execute *proxy.stop(timeout: KAMAL.config.drain_timeout + 10), raise_on_non_zero_exit: false
53
+ execute *proxy.remove_container
54
+
55
+ if (run_config = proxy.proxy_run_config)&.secrets?
56
+ upload! run_config.secrets_io, run_config.secrets_path, mode: "0600"
57
+ else
58
+ # A host keeps no secrets it no longer needs.
59
+ execute *proxy.remove_proxy_secrets_file, raise_on_non_zero_exit: false
60
+ end
61
+
62
+ execute *proxy.run(digest: drift.expected_digest)
63
+ end
64
+
65
+ # Zero-downtime replacement: overlap a new generation on the port-holder's
66
+ # namespace, then drain the old one out.
67
+ def replace_generation
68
+ if holder_running?
69
+ if generation_running?
70
+ handoff_generation
71
+ else
72
+ execute *proxy.remove_container
73
+ execute *proxy.run(digest: drift.expected_digest)
74
+ end
75
+ else
76
+ migrate_to_holder
77
+ end
78
+ end
79
+
80
+ def handoff_generation
81
+ info "Handing off kamal-proxy on #{host} to a new generation (zero downtime)..."
82
+ execute *proxy.remove_stopped_container(name: proxy.next_container_name), raise_on_non_zero_exit: false
83
+ execute *proxy.run(digest: drift.expected_digest, name: proxy.next_container_name)
84
+ wait_until_ready(name: proxy.next_container_name)
85
+
86
+ execute *proxy.disable_restart
87
+ execute *proxy.drain(timeout: KAMAL.config.drain_timeout)
88
+ execute *proxy.wait_for_exit
89
+ execute *proxy.remove_stopped_container
90
+ execute *proxy.promote_next_container
91
+ end
92
+
93
+ # The old proxy still owns the published ports, so the holder cannot bind
94
+ # them until it is gone. One final replacement with a brief gap.
95
+ def migrate_to_holder
96
+ info "Migrating kamal-proxy on #{host} to the port-holder architecture (brief gap)..."
97
+ execute *proxy.stop(timeout: KAMAL.config.drain_timeout + 10), raise_on_non_zero_exit: false
98
+ execute *proxy.remove_container
99
+
100
+ execute *proxy.run_holder
101
+ execute *proxy.run(digest: drift.expected_digest)
102
+ end
103
+
104
+ def holder_running?
105
+ capture_with_info(*proxy.holder_container_id, raise_on_non_zero_exit: false).strip.present?
106
+ end
107
+
108
+ def generation_running?
109
+ capture_with_info(*proxy.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
110
+ end
111
+
112
+ def wait_until_ready(name: nil)
113
+ deadline = Time.now + READY_TIMEOUT
114
+
115
+ begin
116
+ capture_with_info(*proxy.list(**{ name: name }.compact), verbosity: :debug)
117
+ rescue SSHKit::Command::Failed
118
+ raise Kamal::Cli::BootError, "kamal-proxy on #{host} did not become ready within #{READY_TIMEOUT} seconds" if Time.now >= deadline
119
+ sleep 0.5
120
+ retry
121
+ end
122
+ end
123
+
124
+ # Re-register this app's services rather than relying solely on the
125
+ # proxy's own state restore — a proxy that came back with missing routes
126
+ # would otherwise 404 until the next deploy. Returns the registered
127
+ # service names for verify_services to check.
128
+ def re_register_services
129
+ registered_services = []
130
+
131
+ KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
132
+ app = KAMAL.app(role: role, host: host)
133
+
134
+ version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
135
+ next unless version
136
+
137
+ endpoint = capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).strip.presence
138
+ next unless endpoint
139
+
140
+ info "Re-registering #{role.container_prefix} with kamal-proxy on #{host}..."
141
+ execute *app.deploy(target: endpoint)
142
+ registered_services << role.container_prefix
143
+ end
144
+
145
+ KAMAL.config.accessories.select(&:running_proxy?).each do |accessory_config|
146
+ next unless accessory_config.hosts.include?(host.to_s)
147
+
148
+ accessory = KAMAL.accessory(accessory_config.name)
149
+ target = capture_with_info(*accessory.container_id_for(container_name: accessory_config.service_name, only_running: true), raise_on_non_zero_exit: false).strip.presence
150
+ next unless target
151
+
152
+ info "Re-registering #{accessory_config.service_name} with kamal-proxy on #{host}..."
153
+ execute *accessory.deploy(target: target)
154
+ registered_services << accessory_config.service_name
155
+ end
156
+
157
+ registered_services
158
+ end
159
+
160
+ # `list --json` returns {"services": {"<name>": ...}} - exact key
161
+ # membership, where a substring match over the human table would find one
162
+ # service's name inside another's ("app" in "other-app").
163
+ def verify_services(registered_services)
164
+ return if registered_services.empty?
165
+
166
+ listed = JSON.parse(capture_with_info(*proxy.list(json: true))).fetch("services", {}).keys
167
+ missing = registered_services - listed
168
+
169
+ if missing.any?
170
+ raise Kamal::Cli::BootError, "kamal-proxy on #{host} is missing services after reboot: #{missing.join(", ")}"
171
+ end
172
+ end
173
+ end
@@ -14,24 +14,116 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
14
14
  proxy_hosts = proxy_hosts - [ KAMAL.config.proxy.effective_loadbalancer ]
15
15
  end
16
16
 
17
+ drifted_hosts = Concurrent::Array.new
18
+ stale_hosts = Concurrent::Array.new
19
+ auto_reboot = KAMAL.config.proxy.reboot_on_deploy?
20
+
17
21
  on(proxy_hosts) do |host|
18
22
  execute *KAMAL.registry.login
19
23
 
20
- version = capture_with_info(*KAMAL.proxy(host).version).strip.presence
24
+ proxy = KAMAL.proxy(host)
25
+ drift = Kamal::Cli::Proxy::Drift.new(host, self)
26
+
27
+ if drift.drifted? && auto_reboot
28
+ # Leave the old proxy serving until its serial reboot slot below.
29
+ drifted_hosts << host.to_s
30
+ else
31
+ stale_hosts << host.to_s if drift.drifted?
32
+
33
+ version = capture_with_info(*proxy.version).strip.presence
34
+
35
+ if version && Kamal::Utils.older_version?(version, Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)
36
+ raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}"
37
+ end
38
+
39
+ if (run_config = proxy.proxy_run_config)&.secrets?
40
+ execute *proxy.ensure_proxy_directory
41
+ upload! run_config.secrets_io, run_config.secrets_path, mode: "0600"
42
+ else
43
+ # A host keeps no secrets it no longer needs.
44
+ execute *proxy.remove_proxy_secrets_file, raise_on_non_zero_exit: false
45
+ end
46
+
47
+ execute *proxy.ensure_apps_config_directory
48
+ execute *proxy.start_holder_or_run if proxy.port_holder?
49
+ execute *proxy.start_or_run(digest: drift.expected_digest)
50
+ end
51
+ end
52
+
53
+ if stale_hosts.any?
54
+ say "kamal-proxy on #{stale_hosts.sort.join(", ")} is running with a configuration that no longer matches the deploy config. " \
55
+ "Automatic reboot is disabled (proxy: reboot_on_deploy: false) - run `kamal proxy reboot` to apply the new configuration.", :yellow
56
+ end
21
57
 
22
- if version && Kamal::Utils.older_version?(version, Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)
23
- raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}"
58
+ drifted_hosts.sort.each do |host|
59
+ say "kamal-proxy configuration changed, rebooting on #{host}...", :magenta
60
+ run_hook "pre-proxy-reboot", hosts: host
61
+ on(host) do |h|
62
+ Kamal::Cli::Proxy::Reboot.new(h, self).run
24
63
  end
25
- execute *KAMAL.proxy(host).ensure_apps_config_directory
26
- execute *KAMAL.proxy(host).start_or_run
64
+ run_hook "post-proxy-reboot", hosts: host
27
65
  end
28
66
 
29
67
  if KAMAL.config.proxy.load_balancing?
68
+ lb_drifted = Concurrent::Array.new
69
+ lb_stale = Concurrent::Array.new
70
+
30
71
  on(KAMAL.config.proxy.effective_loadbalancer) do |host|
31
72
  info "Starting loadbalancer on #{host}..."
32
73
  execute *KAMAL.registry.login
74
+
75
+ # The load balancer terminates TLS and owns the cache, so its host
76
+ # needs the proxy secrets (acme credentials, cache store) just like
77
+ # the proxy hosts do.
78
+ if (lb_run = KAMAL.loadbalancer_config.run).secrets?
79
+ execute *KAMAL.loadbalancer.ensure_proxy_directory
80
+ upload! lb_run.secrets_io, lb_run.secrets_path, mode: "0600"
81
+ else
82
+ execute *KAMAL.loadbalancer.remove_proxy_secrets_file, raise_on_non_zero_exit: false
83
+ end
84
+
33
85
  execute *KAMAL.loadbalancer.ensure_apps_config_directory
34
- execute *KAMAL.loadbalancer.start_or_run
86
+
87
+ # TLS terminates at the load balancer, so the TLS material the app
88
+ # hosts get - custom certificates and the mTLS client CA - must
89
+ # reach this host too; the LB container reads it through the same
90
+ # apps-config mount the per-host proxies use.
91
+ KAMAL.config.roles.select(&:running_proxy?).each do |role|
92
+ Kamal::Cli::App::SslCertificates.new(host, role, self).run
93
+ end
94
+
95
+ # The same drift detection the proxy hosts get: a loadbalancer booted
96
+ # with a different config digest reboots below (or warns, when
97
+ # automatic reboot is off) instead of serving a stale config forever.
98
+ container_id = capture_with_info(*KAMAL.loadbalancer.container_id, raise_on_non_zero_exit: false).strip
99
+ current_digest = capture_with_info(*KAMAL.loadbalancer.config_digest, raise_on_non_zero_exit: false).strip
100
+
101
+ if container_id.present? && current_digest != KAMAL.loadbalancer_config.run_config_digest
102
+ if auto_reboot
103
+ # Leave the old loadbalancer serving until its reboot below.
104
+ lb_drifted << host.to_s
105
+ else
106
+ lb_stale << host.to_s
107
+ execute *KAMAL.loadbalancer.start_or_run
108
+ end
109
+ else
110
+ Kamal::Cli::Proxy::LoadbalancerClaim.new(host, self).claim_run_config
111
+ execute *KAMAL.loadbalancer.start_or_run
112
+ end
113
+ end
114
+
115
+ if lb_stale.any?
116
+ say "The loadbalancer on #{lb_stale.sort.join(", ")} is running with a configuration that no longer matches the deploy config. " \
117
+ "Automatic reboot is disabled (proxy: reboot_on_deploy: false) - run `kamal proxy reboot` to apply the new configuration.", :yellow
118
+ end
119
+
120
+ lb_drifted.sort.each do |host|
121
+ say "Loadbalancer configuration changed, rebooting on #{host}...", :magenta
122
+ run_hook "pre-loadbalancer-reboot", hosts: host
123
+ on(host) do |h|
124
+ Kamal::Cli::Proxy::LoadbalancerReboot.new(h, self).run
125
+ end
126
+ run_hook "post-loadbalancer-reboot", hosts: host
35
127
  end
36
128
  end
37
129
  end
@@ -137,16 +229,8 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
137
229
  host_list = Array(hosts).join(",")
138
230
  run_hook "pre-proxy-reboot", hosts: host_list
139
231
  on(hosts) do |host|
140
- proxy = KAMAL.proxy(host)
141
- execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
142
- execute *KAMAL.registry.login
143
-
144
- info "Stopping and removing kamal-proxy on #{host}, if running..."
145
- execute *proxy.stop, raise_on_non_zero_exit: false
146
- execute *proxy.remove_container
147
- execute *proxy.ensure_apps_config_directory
148
-
149
- execute *proxy.run
232
+ info "Rebooting kamal-proxy on #{host}..."
233
+ Kamal::Cli::Proxy::Reboot.new(host, self).run
150
234
  end
151
235
  run_hook "post-proxy-reboot", hosts: host_list
152
236
  end
@@ -156,15 +240,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
156
240
  run_hook "pre-loadbalancer-reboot", hosts: lb_host
157
241
 
158
242
  on(lb_host) do |host|
159
- execute *KAMAL.auditor.record("Rebooted loadbalancer"), verbosity: :debug
160
- execute *KAMAL.registry.login
161
-
162
- info "Stopping and removing #{KAMAL.loadbalancer.container_name} on #{host}, if running..."
163
- execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
164
- execute *KAMAL.loadbalancer.remove_container
165
- execute *KAMAL.loadbalancer.ensure_apps_config_directory
166
-
167
- execute *KAMAL.loadbalancer.run
243
+ Kamal::Cli::Proxy::LoadbalancerReboot.new(host, self).run
168
244
  end
169
245
 
170
246
  run_hook "post-loadbalancer-reboot", hosts: lb_host
@@ -221,6 +297,13 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
221
297
  execute *KAMAL.auditor.record("Started proxy"), verbosity: :debug
222
298
  execute *KAMAL.proxy(host).start
223
299
  end
300
+
301
+ if KAMAL.config.proxy.load_balancing?
302
+ on(KAMAL.config.proxy.effective_loadbalancer) do
303
+ execute *KAMAL.auditor.record("Started loadbalancer"), verbosity: :debug
304
+ execute *KAMAL.loadbalancer.start, raise_on_non_zero_exit: false
305
+ end
306
+ end
224
307
  end
225
308
  end
226
309
 
@@ -231,6 +314,16 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
231
314
  execute *KAMAL.auditor.record("Stopped proxy"), verbosity: :debug
232
315
  execute *KAMAL.proxy(host).stop, raise_on_non_zero_exit: false
233
316
  end
317
+
318
+ # `docker container prune` only collects stopped containers, so leaving the
319
+ # loadbalancer running would also leave `kamal proxy remove` unable to
320
+ # remove it.
321
+ if KAMAL.config.proxy.load_balancing?
322
+ on(KAMAL.config.proxy.effective_loadbalancer) do
323
+ execute *KAMAL.auditor.record("Stopped loadbalancer"), verbosity: :debug
324
+ execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
325
+ end
326
+ end
234
327
  end
235
328
  end
236
329
 
@@ -333,16 +426,10 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
333
426
  end
334
427
  when "deploy"
335
428
  if KAMAL.config.proxy.load_balancing?
336
- targets = []
337
- KAMAL.config.roles.each do |role|
338
- next unless role.running_proxy?
339
-
340
- role.hosts.each do |host|
341
- targets << host
342
- end
343
- end
429
+ targets = KAMAL.loadbalancer_config.target_hosts
344
430
 
345
431
  on(KAMAL.config.proxy.effective_loadbalancer) do |host|
432
+ Kamal::Cli::Proxy::LoadbalancerClaim.new(host, self).claim_service
346
433
  info "Deploying to loadbalancer on #{host} with targets: #{targets.join(', ')}"
347
434
  execute *KAMAL.loadbalancer.deploy(targets: targets)
348
435
  end
@@ -354,6 +441,69 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
354
441
  end
355
442
  end
356
443
 
444
+ desc "cache SUBCOMMAND", "Manage the response cache (stats, purge)"
445
+ option :count, type: :boolean, default: false, desc: "stats: measure entries and bytes per service (walks a shared store's keyspace)"
446
+ option :json, type: :boolean, default: false, desc: "stats: print the raw report as JSON"
447
+ option :path_prefix, type: :string, default: nil, desc: "purge: drop only the entries below this path, e.g. /assets"
448
+ def cache(subcommand)
449
+ count, json, path_prefix = options[:count], options[:json], options[:path_prefix]
450
+
451
+ case subcommand
452
+ when "stats"
453
+ # The cache lives where its policy applies: at the loadbalancer when
454
+ # load balancing (cache is edge-only in the layering contract),
455
+ # otherwise on each proxy host.
456
+ if KAMAL.config.proxy.load_balancing?
457
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
458
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_stats(count: count, json: json)), type: "Loadbalancer"
459
+ end
460
+ else
461
+ on(KAMAL.proxy_hosts) do |host|
462
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_stats(count: count, json: json)), type: "Proxy"
463
+ end
464
+ end
465
+ when "purge"
466
+ if KAMAL.config.proxy.load_balancing?
467
+ # The loadbalancer registers one service under the bare service name.
468
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
469
+ execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug
470
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_purge(KAMAL.config.service, path_prefix: path_prefix)), type: "Loadbalancer"
471
+ end
472
+ else
473
+ # Each proxied role registered its own service, so purge walks them.
474
+ on(KAMAL.proxy_hosts) do |host|
475
+ execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug
476
+
477
+ KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
478
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_purge(role.container_prefix, path_prefix: path_prefix)), type: "Proxy"
479
+ end
480
+ end
481
+ end
482
+ else
483
+ puts "Unknown cache subcommand: #{subcommand}. Available: stats, purge"
484
+ end
485
+ end
486
+
487
+ desc "domains SUBCOMMAND", "Manage dynamic TLS domains (refresh, list, stats)"
488
+ def domains(subcommand)
489
+ case subcommand
490
+ when "refresh", "list", "stats"
491
+ # TLS terminates at the load balancer when load balancing, so the
492
+ # dynamic domain set lives there rather than on the per-host proxies.
493
+ if KAMAL.config.proxy.load_balancing?
494
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
495
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.domains(subcommand)), type: "Loadbalancer"
496
+ end
497
+ else
498
+ on(KAMAL.proxy_hosts) do |host|
499
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).domains(subcommand)), type: "Proxy"
500
+ end
501
+ end
502
+ else
503
+ puts "Unknown domains subcommand: #{subcommand}. Available: refresh, list, stats"
504
+ end
505
+ end
506
+
357
507
  desc "remove_container", "Remove proxy container from servers", hide: true
358
508
  def remove_container
359
509
  modify(lock: true) do
@@ -394,12 +544,29 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
394
544
  on(KAMAL.proxy_hosts) do
395
545
  execute *KAMAL.proxy(host).remove_proxy_directory, raise_on_non_zero_exit: false
396
546
  end
547
+
548
+ # Otherwise the ownership registry outlives the load balancer and a later
549
+ # boot would fail against an owner that no longer exists.
550
+ if KAMAL.config.proxy.load_balancing?
551
+ on(KAMAL.config.proxy.effective_loadbalancer) do
552
+ execute *KAMAL.loadbalancer.remove_directory, raise_on_non_zero_exit: false
553
+ end
554
+ end
397
555
  end
398
556
  end
399
557
 
400
558
  private
559
+ # A shared load balancer tier is the exact case this guard exists for, so it
560
+ # has to cover the load balancer host too - `remove_container` and
561
+ # `remove_image` both act on it.
562
+ def removal_hosts
563
+ hosts = KAMAL.proxy_hosts.to_a
564
+ hosts |= [ KAMAL.config.proxy.effective_loadbalancer ] if KAMAL.config.proxy.load_balancing?
565
+ hosts
566
+ end
567
+
401
568
  def removal_allowed?(force)
402
- on(KAMAL.proxy_hosts) do |host|
569
+ on(removal_hosts) do |host|
403
570
  app_count = capture_with_info(*KAMAL.server.app_directory_count).chomp.to_i
404
571
  raise "The are other applications installed on #{host}" if app_count > 0
405
572
  end
@@ -18,16 +18,19 @@ class Kamal::Cli::Prune < Kamal::Cli::Base
18
18
  end
19
19
  end
20
20
 
21
- desc "containers", "Prune all stopped containers, except the last n (default 5)"
22
- option :retain, type: :numeric, default: nil, desc: "Number of containers to retain"
21
+ desc "containers", "Prune all stopped containers, except the last n per role (default 5)"
22
+ option :retain, type: :numeric, default: nil, desc: "Number of containers to retain per role"
23
23
  def containers
24
24
  retain = options.fetch(:retain, KAMAL.config.retain_containers)
25
25
  raise "retain must be at least 1" if retain < 1
26
26
 
27
27
  modify(lock: true) do
28
- on(KAMAL.hosts) do
28
+ on(KAMAL.hosts) do |host|
29
29
  execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug
30
- execute *KAMAL.prune.app_containers(retain: retain)
30
+
31
+ KAMAL.roles_on(host).each do |role|
32
+ execute *KAMAL.prune.app_containers(retain: retain, role: role)
33
+ end
31
34
  end
32
35
  end
33
36
  end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env sh
2
+
3
+ # Runs on each host just after the previous container has been stopped.
4
+ #
5
+ # NOTE: $KAMAL_VERSION is the version that was STOPPED, not the one being deployed.
6
+ #
7
+ # A failure here is logged and the deploy continues.
8
+
9
+ echo "Stopped $KAMAL_ROLE version $KAMAL_VERSION on $KAMAL_HOSTS"
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ echo "Routed traffic for $KAMAL_ROLE version $KAMAL_VERSION on $KAMAL_HOSTS"
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env sh
2
+
3
+ # Runs on each host just before the previous container is stopped, once the new
4
+ # version is already live. Use it to drain the old container: unsubscribe a queue
5
+ # group, deregister from service discovery, wait for in-flight jobs.
6
+ #
7
+ # NOTE: $KAMAL_VERSION is the version being STOPPED, not the one being deployed.
8
+ #
9
+ # A failure here is logged and the deploy continues -- the old container is
10
+ # stopped either way.
11
+
12
+ echo "Draining $KAMAL_ROLE version $KAMAL_VERSION on $KAMAL_HOSTS..."
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ echo "Routing traffic for $KAMAL_ROLE version $KAMAL_VERSION on $KAMAL_HOSTS..."
data/lib/kamal/cli.rb CHANGED
@@ -3,6 +3,7 @@ module Kamal::Cli
3
3
  class HookError < StandardError; end
4
4
  class LockError < StandardError; end
5
5
  class DependencyError < StandardError; end
6
+ class DoctorError < StandardError; end
6
7
  end
7
8
 
8
9
  # SSHKit uses instance eval, so we need a global const for ergonomics
@@ -9,6 +9,18 @@ module Kamal::Commands::App::Proxy
9
9
  proxy_exec :remove, role.container_prefix
10
10
  end
11
11
 
12
+ def rollout_deploy(target:)
13
+ proxy_exec :rollout, :deploy, role.container_prefix, *role.proxy.rollout_deploy_command_args(target: target)
14
+ end
15
+
16
+ def rollout_set(percent: nil, list: nil)
17
+ proxy_exec :rollout, :set, role.container_prefix, *role.proxy.rollout_set_command_args(percent: percent, list: list)
18
+ end
19
+
20
+ def rollout_stop
21
+ proxy_exec :rollout, :stop, role.container_prefix
22
+ end
23
+
12
24
  def live
13
25
  proxy_exec :resume, role.container_prefix
14
26
  end
@@ -30,6 +30,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
30
30
  *role.asset_volume_args,
31
31
  *role.label_args,
32
32
  *role.option_args,
33
+ *role.healthcheck_args,
33
34
  config.absolute_image,
34
35
  role.cmd
35
36
  end
@@ -42,6 +43,13 @@ class Kamal::Commands::App < Kamal::Commands::Base
42
43
  pipe container_id_for_version(version), xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT))
43
44
  end
44
45
 
46
+ # The `healthcheck: exec:` probe, run from the deploy host against the container that is
47
+ # booting. Single-quoted by #shell, so the command expands inside the container rather
48
+ # than in the deploy host's shell. Non-zero exit means not ready.
49
+ def health_probe(version:)
50
+ docker :exec, container_name(version), *shell([ role.healthcheck.exec ])
51
+ end
52
+
45
53
  def stop(version: nil)
46
54
  pipe \
47
55
  version ? container_id_for_version(version) : current_running_container_id,
@@ -2,7 +2,13 @@ module Kamal::Commands
2
2
  class Base
3
3
  delegate :sensitive, :argumentize, to: Kamal::Utils
4
4
 
5
- DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'"
5
+ # Prefixed onto the docker state when the container declares no healthcheck at all, so
6
+ # "nothing is probing this container" stays distinguishable from "the probe passed" —
7
+ # both used to reach the poller as `running`. The state is kept after the prefix so a
8
+ # healthcheck-less container that died still reports why.
9
+ NO_HEALTHCHECK = "no-healthcheck"
10
+
11
+ DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}#{NO_HEALTHCHECK}:{{.State.Status}}{{end}}'"
6
12
 
7
13
  attr_accessor :config
8
14
 
@@ -34,6 +40,10 @@ module Kamal::Commands
34
40
  [ :rm, path ]
35
41
  end
36
42
 
43
+ def read_file(file, default: nil)
44
+ combine [ :cat, file, "2>", "/dev/null" ], [ :echo, "\"#{default}\"" ], by: "||"
45
+ end
46
+
37
47
  def ensure_docker_installed
38
48
  combine \
39
49
  ensure_local_docker_installed,
@@ -35,6 +35,11 @@ class Kamal::Commands::Docker < Kamal::Commands::Base
35
35
  [ "kill -HUP $PPID" ]
36
36
  end
37
37
 
38
+ # Checks that an image's manifest can be fetched from its registry. Fails if it's missing or we're unauthorized.
39
+ def manifest_available?(image)
40
+ docker :manifest, :inspect, image
41
+ end
42
+
38
43
  def create_network
39
44
  docker :network, :create, :kamal
40
45
  end