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,172 @@
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.drain(timeout: KAMAL.config.drain_timeout)
87
+ execute *proxy.wait_for_exit
88
+ execute *proxy.remove_stopped_container
89
+ execute *proxy.promote_next_container
90
+ end
91
+
92
+ # The old proxy still owns the published ports, so the holder cannot bind
93
+ # them until it is gone. One final replacement with a brief gap.
94
+ def migrate_to_holder
95
+ info "Migrating kamal-proxy on #{host} to the port-holder architecture (brief gap)..."
96
+ execute *proxy.stop(timeout: KAMAL.config.drain_timeout + 10), raise_on_non_zero_exit: false
97
+ execute *proxy.remove_container
98
+
99
+ execute *proxy.run_holder
100
+ execute *proxy.run(digest: drift.expected_digest)
101
+ end
102
+
103
+ def holder_running?
104
+ capture_with_info(*proxy.holder_container_id, raise_on_non_zero_exit: false).strip.present?
105
+ end
106
+
107
+ def generation_running?
108
+ capture_with_info(*proxy.container_id(only_running: true), raise_on_non_zero_exit: false).strip.present?
109
+ end
110
+
111
+ def wait_until_ready(name: nil)
112
+ deadline = Time.now + READY_TIMEOUT
113
+
114
+ begin
115
+ capture_with_info(*proxy.list(**{ name: name }.compact), verbosity: :debug)
116
+ rescue SSHKit::Command::Failed
117
+ raise Kamal::Cli::BootError, "kamal-proxy on #{host} did not become ready within #{READY_TIMEOUT} seconds" if Time.now >= deadline
118
+ sleep 0.5
119
+ retry
120
+ end
121
+ end
122
+
123
+ # Re-register this app's services rather than relying solely on the
124
+ # proxy's own state restore — a proxy that came back with missing routes
125
+ # would otherwise 404 until the next deploy. Returns the registered
126
+ # service names for verify_services to check.
127
+ def re_register_services
128
+ registered_services = []
129
+
130
+ KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
131
+ app = KAMAL.app(role: role, host: host)
132
+
133
+ version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
134
+ next unless version
135
+
136
+ endpoint = capture_with_info(*app.container_id_for_version(version, only_running: true), raise_on_non_zero_exit: false).strip.presence
137
+ next unless endpoint
138
+
139
+ info "Re-registering #{role.container_prefix} with kamal-proxy on #{host}..."
140
+ execute *app.deploy(target: endpoint)
141
+ registered_services << role.container_prefix
142
+ end
143
+
144
+ KAMAL.config.accessories.select(&:running_proxy?).each do |accessory_config|
145
+ next unless accessory_config.hosts.include?(host.to_s)
146
+
147
+ accessory = KAMAL.accessory(accessory_config.name)
148
+ 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
149
+ next unless target
150
+
151
+ info "Re-registering #{accessory_config.service_name} with kamal-proxy on #{host}..."
152
+ execute *accessory.deploy(target: target)
153
+ registered_services << accessory_config.service_name
154
+ end
155
+
156
+ registered_services
157
+ end
158
+
159
+ # `list --json` returns {"services": {"<name>": ...}} - exact key
160
+ # membership, where a substring match over the human table would find one
161
+ # service's name inside another's ("app" in "other-app").
162
+ def verify_services(registered_services)
163
+ return if registered_services.empty?
164
+
165
+ listed = JSON.parse(capture_with_info(*proxy.list(json: true))).fetch("services", {}).keys
166
+ missing = registered_services - listed
167
+
168
+ if missing.any?
169
+ raise Kamal::Cli::BootError, "kamal-proxy on #{host} is missing services after reboot: #{missing.join(", ")}"
170
+ end
171
+ end
172
+ 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
21
34
 
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}"
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)
24
50
  end
25
- execute *KAMAL.proxy(host).ensure_apps_config_directory
26
- execute *KAMAL.proxy(host).start_or_run
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
57
+
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
63
+ end
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
 
@@ -343,6 +436,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
343
436
  end
344
437
 
345
438
  on(KAMAL.config.proxy.effective_loadbalancer) do |host|
439
+ Kamal::Cli::Proxy::LoadbalancerClaim.new(host, self).claim_service
346
440
  info "Deploying to loadbalancer on #{host} with targets: #{targets.join(', ')}"
347
441
  execute *KAMAL.loadbalancer.deploy(targets: targets)
348
442
  end
@@ -354,6 +448,69 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
354
448
  end
355
449
  end
356
450
 
451
+ desc "cache SUBCOMMAND", "Manage the response cache (stats, purge)"
452
+ option :count, type: :boolean, default: false, desc: "stats: measure entries and bytes per service (walks a shared store's keyspace)"
453
+ option :json, type: :boolean, default: false, desc: "stats: print the raw report as JSON"
454
+ option :path_prefix, type: :string, default: nil, desc: "purge: drop only the entries below this path, e.g. /assets"
455
+ def cache(subcommand)
456
+ count, json, path_prefix = options[:count], options[:json], options[:path_prefix]
457
+
458
+ case subcommand
459
+ when "stats"
460
+ # The cache lives where its policy applies: at the loadbalancer when
461
+ # load balancing (cache is edge-only in the layering contract),
462
+ # otherwise on each proxy host.
463
+ if KAMAL.config.proxy.load_balancing?
464
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
465
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_stats(count: count, json: json)), type: "Loadbalancer"
466
+ end
467
+ else
468
+ on(KAMAL.proxy_hosts) do |host|
469
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_stats(count: count, json: json)), type: "Proxy"
470
+ end
471
+ end
472
+ when "purge"
473
+ if KAMAL.config.proxy.load_balancing?
474
+ # The loadbalancer registers one service under the bare service name.
475
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
476
+ execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug
477
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.cache_purge(KAMAL.config.service, path_prefix: path_prefix)), type: "Loadbalancer"
478
+ end
479
+ else
480
+ # Each proxied role registered its own service, so purge walks them.
481
+ on(KAMAL.proxy_hosts) do |host|
482
+ execute *KAMAL.auditor.record("Purged the response cache"), verbosity: :debug
483
+
484
+ KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
485
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).cache_purge(role.container_prefix, path_prefix: path_prefix)), type: "Proxy"
486
+ end
487
+ end
488
+ end
489
+ else
490
+ puts "Unknown cache subcommand: #{subcommand}. Available: stats, purge"
491
+ end
492
+ end
493
+
494
+ desc "domains SUBCOMMAND", "Manage dynamic TLS domains (refresh, list, stats)"
495
+ def domains(subcommand)
496
+ case subcommand
497
+ when "refresh", "list", "stats"
498
+ # TLS terminates at the load balancer when load balancing, so the
499
+ # dynamic domain set lives there rather than on the per-host proxies.
500
+ if KAMAL.config.proxy.load_balancing?
501
+ on(KAMAL.config.proxy.effective_loadbalancer) do |host|
502
+ puts_by_host host, capture_with_info(*KAMAL.loadbalancer.domains(subcommand)), type: "Loadbalancer"
503
+ end
504
+ else
505
+ on(KAMAL.proxy_hosts) do |host|
506
+ puts_by_host host, capture_with_info(*KAMAL.proxy(host).domains(subcommand)), type: "Proxy"
507
+ end
508
+ end
509
+ else
510
+ puts "Unknown domains subcommand: #{subcommand}. Available: refresh, list, stats"
511
+ end
512
+ end
513
+
357
514
  desc "remove_container", "Remove proxy container from servers", hide: true
358
515
  def remove_container
359
516
  modify(lock: true) do
@@ -394,12 +551,29 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
394
551
  on(KAMAL.proxy_hosts) do
395
552
  execute *KAMAL.proxy(host).remove_proxy_directory, raise_on_non_zero_exit: false
396
553
  end
554
+
555
+ # Otherwise the ownership registry outlives the load balancer and a later
556
+ # boot would fail against an owner that no longer exists.
557
+ if KAMAL.config.proxy.load_balancing?
558
+ on(KAMAL.config.proxy.effective_loadbalancer) do
559
+ execute *KAMAL.loadbalancer.remove_directory, raise_on_non_zero_exit: false
560
+ end
561
+ end
397
562
  end
398
563
  end
399
564
 
400
565
  private
566
+ # A shared load balancer tier is the exact case this guard exists for, so it
567
+ # has to cover the load balancer host too - `remove_container` and
568
+ # `remove_image` both act on it.
569
+ def removal_hosts
570
+ hosts = KAMAL.proxy_hosts.to_a
571
+ hosts |= [ KAMAL.config.proxy.effective_loadbalancer ] if KAMAL.config.proxy.load_balancing?
572
+ hosts
573
+ end
574
+
401
575
  def removal_allowed?(force)
402
- on(KAMAL.proxy_hosts) do |host|
576
+ on(removal_hosts) do |host|
403
577
  app_count = capture_with_info(*KAMAL.server.app_directory_count).chomp.to_i
404
578
  raise "The are other applications installed on #{host}" if app_count > 0
405
579
  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