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
@@ -8,6 +8,32 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
8
8
  super
9
9
  validate_labels!(config["labels"])
10
10
  validate_docker_options!(config["options"])
11
+ validate_healthcheck_options!(config["healthcheck"], config["options"])
11
12
  end
12
13
  end
14
+
15
+ private
16
+ # `healthcheck: false` is the explicit opt-out from the readiness gate, so the
17
+ # example's hash shape is not the only legal one.
18
+ def validate_key_override!(key, value)
19
+ return false unless key.to_s == "healthcheck"
20
+
21
+ case value
22
+ when false then true
23
+ when Hash then false
24
+ else error "should be a hash, or false to accept no readiness gate for this role"
25
+ end
26
+ end
27
+
28
+ # Docker takes the last --health-* flag it sees, so overlapping sources would
29
+ # silently pick a winner. Refuse the ambiguity instead.
30
+ def validate_healthcheck_options!(healthcheck, options)
31
+ return if healthcheck.blank?
32
+
33
+ if health_option = options&.find { |key, _| key.to_s.start_with?("health-") }
34
+ with_context("healthcheck") do
35
+ error "cannot be combined with options/#{health_option.first}, remove one of them"
36
+ end
37
+ end
38
+ end
13
39
  end
@@ -27,7 +27,7 @@ class Kamal::Configuration::Validator
27
27
  unless key.to_s == "proxy" && boolean?(value.class)
28
28
  validate_type! value, *(Array if key == :servers), Hash
29
29
  end
30
- elsif key.to_s == "ssl"
30
+ elsif key.to_s == "ssl" || key.to_s == "compress"
31
31
  validate_type! value, TrueClass, FalseClass, Hash
32
32
  elsif key.to_s == "hooks_output"
33
33
  validate_hooks_output!(value)
@@ -47,10 +47,10 @@ class Kamal::Configuration::Validator
47
47
  case key.to_s
48
48
  when "options", "args"
49
49
  validate_type! value, Hash
50
- when "labels"
50
+ when "labels", "path_response_timeouts", "path_request_timeouts"
51
51
  validate_hash_of! value, example_value.first[1].class
52
52
  else
53
- validate_against_example! value, example_value
53
+ validate_against_example! value, example_value unless validate_key_override!(key, value)
54
54
  end
55
55
  else
56
56
  validate_type! value, example_value.class
@@ -180,6 +180,12 @@ class Kamal::Configuration::Validator
180
180
  type_error(*types) unless types.any? { |type| valid_type?(value, type) }
181
181
  end
182
182
 
183
+ # Hook for subclasses that accept a shape the example cannot express. Return true
184
+ # when the key was fully validated here and the example should be skipped.
185
+ def validate_key_override!(key, value)
186
+ false
187
+ end
188
+
183
189
  def error(message)
184
190
  raise Kamal::ConfigurationError, "#{error_context}#{message}"
185
191
  end
@@ -235,6 +241,23 @@ class Kamal::Configuration::Validator
235
241
  if restart_policy = options&.find { |key, _| key.to_s == "restart" }
236
242
  validate_restart_policy!(restart_policy.last)
237
243
  end
244
+
245
+ validate_health_option_expansion!(options)
246
+ end
247
+
248
+ # Kamal::Utils::DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX escapes a bare `$` but exempts
249
+ # `${...}`, leaving it for the deploy host's shell to expand at docker run time. Role env
250
+ # never reaches that shell, so `${SOME_ENV}` in a health command silently becomes empty and
251
+ # the container sits `starting` until deploy_timeout. Reject it where it is most likely written.
252
+ def validate_health_option_expansion!(options)
253
+ options&.each do |key, value|
254
+ next unless key.to_s.start_with?("health-")
255
+ next unless Array(value).any? { |entry| entry.to_s.include?("${") }
256
+
257
+ with_context("options/#{key}") do
258
+ error "cannot contain ${...}, which the deploy host's shell expands at docker run time, not the container — a role env var resolves to empty. Use the bare $VAR form, which expands in the container, or a literal value"
259
+ end
260
+ end
238
261
  end
239
262
 
240
263
  def validate_restart_policy!(restart_policy)
@@ -87,7 +87,42 @@ class Kamal::Configuration
87
87
  ensure_unique_hosts_for_ssl_roles
88
88
  ensure_local_registry_remote_builder_has_ssh_url
89
89
  ensure_no_conflicting_proxy_runs
90
+ ensure_valid_loadbalancer
90
91
  ensure_valid_hooks_output!
92
+ ensure_unproxied_roles_are_readiness_gated
93
+ ensure_role_boot_can_pace_its_hosts
94
+ ensure_boot_wait_paces_something
95
+ ensure_rate_limit_can_identify_clients
96
+ ensure_intercepted_errors_have_pages
97
+ ensure_sleep_has_a_docker_socket
98
+ ensure_proxy_protocol_names_its_peers
99
+ ensure_max_idle_conns_meaningful
100
+ end
101
+
102
+ # Resolves every secret the deploy will need so a missing secret fails fast,
103
+ # before any registry login or SSH connection. Secret adapters run here
104
+ # instead of mid-deploy — same work, done earlier.
105
+ def validate_secrets!(include_accessories: false)
106
+ registry.username
107
+ registry.password
108
+ builder.secrets
109
+
110
+ roles.each do |role|
111
+ role.secrets_io(role.hosts.first) if role.hosts.any?
112
+
113
+ if role.running_proxy?
114
+ role.proxy.run&.secrets_io
115
+
116
+ if role.proxy.custom_ssl_certificate?
117
+ role.proxy.certificate_pem_content
118
+ role.proxy.private_key_pem_content
119
+ end
120
+ end
121
+ end
122
+
123
+ accessories.each(&:secrets_io) if include_accessories
124
+
125
+ true
91
126
  end
92
127
 
93
128
  def version=(version)
@@ -137,6 +172,14 @@ class Kamal::Configuration
137
172
  roles.select { |role| role.hosts.include?(host) }
138
173
  end
139
174
 
175
+ # Whether app commands iterate role-first. A role pacing its own hosts needs that: the
176
+ # host-first branch of on_roles has no per-role runner to hand boot options to. Setting
177
+ # `boot` on a role only overrides an unset parallel_roles — an explicit false conflicts
178
+ # and is rejected in ensure_role_boot_can_pace_its_hosts.
179
+ def parallel_roles?
180
+ !!(boot.parallel_roles || roles.any?(&:boot))
181
+ end
182
+
140
183
  def host_accessories(host)
141
184
  accessories.select { |accessory| accessory.hosts.include?(host) }
142
185
  end
@@ -400,6 +443,150 @@ class Kamal::Configuration
400
443
  true
401
444
  end
402
445
 
446
+ # A role without a proxy and without a healthcheck has no readiness gate: the deploy
447
+ # accepts the container as ready once it is merely still running after readiness_delay,
448
+ # then stops the old one. Warn-only for now so existing configs keep deploying.
449
+ def ensure_unproxied_roles_are_readiness_gated
450
+ offenders = roles.reject { |role| role.hosts.empty? || role.running_proxy? || role.readiness_gated? }
451
+ return true if offenders.empty?
452
+
453
+ warn "Non-proxied role(s) #{offenders.map(&:name).join(", ")} have no healthcheck. " \
454
+ "Without one, Kamal accepts the container as ready #{readiness_delay}s after it merely starts " \
455
+ "and then stops the previous container — traffic can be dropped. Add a `healthcheck:` block, " \
456
+ "or opt out explicitly with `healthcheck: false`. This warning will become an error in a future release."
457
+
458
+ true
459
+ end
460
+
461
+ # A PROXY protocol header rewrites the connecting address, which is the one
462
+ # thing on a request nothing else can influence — and the one every access
463
+ # control decision keys on. Honouring it from any peer that can reach the
464
+ # port hands that address to whoever asks. Warn rather than raise: a proxy on
465
+ # a private network with no other route in is a legitimate configuration.
466
+ def ensure_proxy_protocol_names_its_peers
467
+ offenders = all_hosts.select { |host| proxy_run(host)&.proxy_protocol_unrestricted? }
468
+ return true if offenders.empty?
469
+
470
+ warn "Host(s) #{offenders.sort.join(", ")}: proxy_protocol is enabled without proxy_protocol_allow_ips, " \
471
+ "so kamal-proxy honours a PROXY header from any peer that can reach the port. That header sets the client " \
472
+ "address `allow_ips`, `rate_limit` and the access log all use. Name the load balancers you actually run."
473
+
474
+ true
475
+ end
476
+
477
+ # Scale-to-zero is a deploy-time setting with a boot-time prerequisite: the
478
+ # proxy can only stop and start containers through the runtime socket, and it
479
+ # is only mounted when proxy/run/docker_socket names it. Without that, the
480
+ # first request after an idle period hangs until wake_timeout and 503s, which
481
+ # reads as a broken proxy rather than as a missing key. Say the key instead.
482
+ #
483
+ # Checked here rather than in Validator::Proxy because a role may carry
484
+ # `sleep` while the root carries the socket — role.proxy is the merged view,
485
+ # the validator only ever sees one side.
486
+ def ensure_sleep_has_a_docker_socket
487
+ offenders = roles.select do |role|
488
+ next false unless role.running_proxy?
489
+
490
+ role.proxy.proxy_config.dig("sleep", "after").present? && role.proxy.run&.docker_socket.blank?
491
+ end
492
+
493
+ return true if offenders.empty?
494
+
495
+ raise Kamal::ConfigurationError, "Role(s) #{offenders.map(&:name).join(", ")}: " \
496
+ "proxy/sleep requires proxy/run/docker_socket - kamal-proxy can only stop and start containers " \
497
+ "through the container runtime socket, and it is not mounted into the proxy without it"
498
+ end
499
+
500
+ # kamal-proxy resolves a zero max_idle_conns to its default of 100
501
+ # (target_pool.go resolves defaults from zeros), so the one value an
502
+ # operator writes to mean "keep none" is the one value that cannot mean it.
503
+ # Legal, so warn rather than raise.
504
+ def ensure_max_idle_conns_meaningful
505
+ offenders = roles.select do |role|
506
+ role.running_proxy? && role.proxy.proxy_config.dig("target", "max_idle_conns") == 0
507
+ end
508
+ return true if offenders.empty?
509
+
510
+ warn "Role(s) #{offenders.map(&:name).join(", ")}: target/max_idle_conns: 0 means the proxy default of 100, " \
511
+ "not \"keep none\" - kamal-proxy resolves its defaults from zeros. Leave it unset for the default, " \
512
+ "or set 1 for the practical minimum."
513
+
514
+ true
515
+ end
516
+
517
+ # `intercept_errors` discards the app's error body and renders the proxy's own
518
+ # page instead. With no pages to render, kamal-proxy falls back to a bare
519
+ # plaintext status line — so the app's error page is thrown away and replaced
520
+ # by the words "Bad Gateway". Legal, and almost never the intent.
521
+ def ensure_intercepted_errors_have_pages
522
+ return true if error_pages_path.present?
523
+
524
+ offenders = roles.select { |role| role.running_proxy? && role.proxy.proxy_config["intercept_errors"].present? }
525
+ return true if offenders.empty?
526
+
527
+ warn "Role(s) #{offenders.map(&:name).join(", ")}: intercept_errors is set but no error_pages_path is " \
528
+ "configured, so kamal-proxy replaces the app's error page with a bare plaintext status line. " \
529
+ "Set `error_pages_path:` to serve your own pages, or remove `intercept_errors` to let the app's through."
530
+
531
+ true
532
+ end
533
+
534
+ # A rate limiter is only as correct as the address it keys on. `forward_headers:
535
+ # true` says something sits in front of the proxy, and without `trusted_proxies`
536
+ # kamal-proxy keys on that something's address rather than the client's — so the
537
+ # limiter throttles the whole world as one client, or nobody at all. Warn rather
538
+ # than raise: the config is legal, just almost certainly not what was meant.
539
+ def ensure_rate_limit_can_identify_clients
540
+ offenders = roles.select do |role|
541
+ next false unless role.running_proxy?
542
+
543
+ proxy_config = role.proxy.proxy_config
544
+ proxy_config.dig("rate_limit", "requests").present? &&
545
+ proxy_config["forward_headers"] &&
546
+ Array(proxy_config.dig("client_ip", "trusted_proxies")).empty?
547
+ end
548
+
549
+ return true if offenders.empty?
550
+
551
+ warn "Role(s) #{offenders.map(&:name).join(", ")}: rate_limit is set with forward_headers, " \
552
+ "but no proxy/client_ip/trusted_proxies. kamal-proxy will key the limiter on the address of " \
553
+ "whatever sits in front of it, not on the client's — so it throttles every visitor as one " \
554
+ "client, or none of them. Declare the proxies in front with `client_ip: trusted_proxies:`."
555
+
556
+ true
557
+ end
558
+
559
+ # `parallel_roles: false` iterates host-first, running each host's roles one after
560
+ # another inside a single per-host thread. There is no per-role runner there to pace,
561
+ # so the two keys cannot both be honoured — say which one to drop rather than quietly
562
+ # ignoring either.
563
+ def ensure_role_boot_can_pace_its_hosts
564
+ # select, not find: this is also where every role-scoped Boot gets built, so a bad
565
+ # one raises here rather than partway through a deploy.
566
+ paced = roles.select(&:boot)
567
+ return true if paced.empty? || boot.parallel_roles != false
568
+
569
+ raise Kamal::ConfigurationError, "servers/#{paced.first.name}/boot cannot be combined with boot/parallel_roles: false, " \
570
+ "which boots each host's roles in turn and so cannot pace one role's hosts. Remove one of them"
571
+ end
572
+
573
+ # `wait` only ever fills the gap between one group of hosts and the next, and without a
574
+ # `limit` there are no gaps — every host boots in one group. Before #47 that silently
575
+ # cost a deploy one `wait` interval; now it silently does nothing at all. Either way the
576
+ # operator asked for staggering and is not getting it, so say so.
577
+ def ensure_boot_wait_paces_something
578
+ offenders = [ [ "boot", boot ], *roles.filter_map { |role| [ "servers/#{role.name}/boot", role.boot ] if role.boot } ]
579
+ .select { |_context, boot_config| boot_config.wait.present? && !boot_config.limit? }
580
+
581
+ offenders.each do |context, boot_config|
582
+ warn "#{context}/wait is set to #{boot_config.wait} but #{context}/limit is not, so it does nothing. " \
583
+ "`wait` paces one group of hosts against the next, and without a limit every host boots in a single group. " \
584
+ "Set `#{context}/limit` to stagger the boot, or remove `#{context}/wait`."
585
+ end
586
+
587
+ true
588
+ end
589
+
403
590
  def ensure_unique_hosts_for_ssl_roles
404
591
  hosts = roles.select(&:ssl?).flat_map { |role| role.proxy.hosts }
405
592
  duplicates = hosts.tally.filter_map { |host, count| host if count > 1 }
@@ -432,6 +619,16 @@ class Kamal::Configuration
432
619
  (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact
433
620
  end
434
621
 
622
+ # `loadbalancer: true` resolves to the primary role's first host, so an
623
+ # accessories-only configuration has nothing to run it on.
624
+ def ensure_valid_loadbalancer
625
+ if proxy.loadbalancer == true && primary_role.nil?
626
+ raise Kamal::ConfigurationError, "proxy/loadbalancer: can't be enabled without servers"
627
+ end
628
+
629
+ true
630
+ end
631
+
435
632
  def role_names
436
633
  raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
437
634
  end
@@ -209,6 +209,26 @@ class SSHKit::Runner::Parallel
209
209
  prepend CompleteAll
210
210
  end
211
211
 
212
+ class SSHKit::Runner::Group
213
+ # SSHKit sleeps after every slice, including the last, so a paced role pays one wait
214
+ # interval buying nothing — there is no next group for it to pace against. Kamal's boot
215
+ # wait means "hold off before starting the next group", which is what Sequential already
216
+ # does by popping its last host.
217
+ module NoTrailingWait
218
+ def execute
219
+ slices = hosts.each_slice(group_size).to_a
220
+
221
+ slices.each_with_index.flat_map do |group_hosts, index|
222
+ result = SSHKit::Runner::Parallel.new(group_hosts, &block).execute
223
+ sleep wait_interval if index < slices.size - 1
224
+ result
225
+ end
226
+ end
227
+ end
228
+
229
+ prepend NoTrailingWait
230
+ end
231
+
212
232
  # Avoid net-ssh debug, until https://github.com/net-ssh/net-ssh/pull/953 is merged
213
233
  module NetSshForwardingNoPuts
214
234
  def puts(*)
@@ -228,17 +248,22 @@ module SSHKitDslRoles
228
248
  # parallel: When true, each role runs in its own thread with separate
229
249
  # connections. When false, hosts run in parallel but roles on each
230
250
  # host run sequentially (default: true)
251
+ # rolling: When true, each role's own `boot` config paces its hosts through
252
+ # the SSHKit runner, so one role can boot serially while its
253
+ # siblings still boot in parallel. Only for commands that start
254
+ # containers — it would needlessly serialize read-only commands.
255
+ # Requires parallel: there is no per-role runner otherwise.
231
256
  #
232
257
  # Example:
233
258
  # on_roles(roles) do |host, role|
234
259
  # # deploy role to host
235
260
  # end
236
- def on_roles(roles, hosts:, parallel: true, &block)
261
+ def on_roles(roles, hosts:, parallel: true, rolling: false, &block)
237
262
  if parallel
238
263
  threads = roles.filter_map do |role|
239
264
  if (role_hosts = role.hosts & hosts).any?
240
265
  Thread.new do
241
- on(role_hosts) { |host| instance_exec(host, role, &block) }
266
+ on(role_hosts, rolling ? role.boot_runner_options(role_hosts) : {}) { |host| instance_exec(host, role, &block) }
242
267
  rescue StandardError => e
243
268
  raise SSHKit::Runner::ExecuteError.new(e), "Exception while executing on #{role}: #{e.message}"
244
269
  end
data/lib/kamal/utils.rb CHANGED
@@ -21,16 +21,36 @@ module Kamal::Utils
21
21
  end
22
22
 
23
23
  # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.
24
+ # A Sensitive value stays Sensitive: the rendered option keeps the real value
25
+ # for execution and a redacted form for anything kamal prints - same contract
26
+ # as argumentize's `sensitive:` kwarg, but decided per value by the caller.
24
27
  def optionize(args, with: nil, escape: true)
25
28
  options = if with
26
- flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape ? escape_shell_value(value) : value}" }
29
+ flatten_args(args).collect do |(key, value)|
30
+ if value == true
31
+ "--#{key}"
32
+ else
33
+ rendered = "--#{key}#{with}#{escape ? escape_shell_value(value) : value}"
34
+ value.is_a?(Kamal::Utils::Sensitive) ? sensitive(rendered, redaction: "--#{key}#{with}#{value.redaction}") : rendered
35
+ end
36
+ end
27
37
  else
28
- flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape ? escape_shell_value(value) : value ] }
38
+ flatten_args(args).collect do |(key, value)|
39
+ rendered = value == true ? nil : escape ? escape_shell_value(value) : value
40
+ rendered = sensitive(rendered, redaction: value.redaction) if value.is_a?(Kamal::Utils::Sensitive)
41
+ [ "--#{key}", rendered ]
42
+ end
29
43
  end
30
44
 
31
45
  options.flatten.compact
32
46
  end
33
47
 
48
+ # kamal-proxy takes durations as Go duration strings; deploy.yml takes plain
49
+ # seconds. Zero is a real value (it disables the timeout), so only nil drops out.
50
+ def seconds_duration(value)
51
+ "#{value}s" if value
52
+ end
53
+
34
54
  # Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair
35
55
  def flatten_args(args)
36
56
  args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] }
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.12.0"
2
+ VERSION = "3.0.0"
3
3
  end
data/lib/kamal.rb CHANGED
@@ -8,6 +8,17 @@ require "yaml"
8
8
  require "tmpdir"
9
9
  require "pathname"
10
10
  require "uri"
11
+ # Proxy::Run.digest reaches for Digest::SHA256 from inside `on(KAMAL.proxy_hosts)`,
12
+ # so the first reference happens in several SSHKit threads at once. Digest defines
13
+ # SHA256 lazily — `Digest.const_missing(:SHA256)` runs `require "digest/sha2"`, and
14
+ # that path is not mutex-guarded — so the threads race it and one loses with
15
+ # "Digest::Base cannot be directly inherited in Ruby", failing `kamal proxy boot`
16
+ # on a random host.
17
+ #
18
+ # Requiring "digest" alone does NOT fix this: it defines the module but leaves
19
+ # SHA256 to const_missing. Pull in the implementation itself, before any threads
20
+ # exist to race it.
21
+ require "digest/sha2"
11
22
 
12
23
  loader = Zeitwerk::Loader.for_gem
13
24
  loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb"))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dash
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -236,17 +236,27 @@ files:
236
236
  - lib/kamal/cli/app/assets.rb
237
237
  - lib/kamal/cli/app/boot.rb
238
238
  - lib/kamal/cli/app/error_pages.rb
239
+ - lib/kamal/cli/app/rollout_boot.rb
239
240
  - lib/kamal/cli/app/ssl_certificates.rb
240
241
  - lib/kamal/cli/base.rb
241
242
  - lib/kamal/cli/build.rb
242
243
  - lib/kamal/cli/build/clone.rb
243
244
  - lib/kamal/cli/build/port_forwarding.rb
245
+ - lib/kamal/cli/doctor.rb
246
+ - lib/kamal/cli/doctor/config_checks.rb
247
+ - lib/kamal/cli/doctor/endpoint_checks.rb
248
+ - lib/kamal/cli/doctor/host_checks.rb
244
249
  - lib/kamal/cli/healthcheck/barrier.rb
250
+ - lib/kamal/cli/healthcheck/drift_error.rb
245
251
  - lib/kamal/cli/healthcheck/error.rb
246
252
  - lib/kamal/cli/healthcheck/poller.rb
247
253
  - lib/kamal/cli/lock.rb
248
254
  - lib/kamal/cli/main.rb
249
255
  - lib/kamal/cli/proxy.rb
256
+ - lib/kamal/cli/proxy/drift.rb
257
+ - lib/kamal/cli/proxy/loadbalancer_claim.rb
258
+ - lib/kamal/cli/proxy/loadbalancer_reboot.rb
259
+ - lib/kamal/cli/proxy/reboot.rb
250
260
  - lib/kamal/cli/prune.rb
251
261
  - lib/kamal/cli/registry.rb
252
262
  - lib/kamal/cli/secrets.rb
@@ -254,12 +264,16 @@ files:
254
264
  - lib/kamal/cli/templates/deploy.yml
255
265
  - lib/kamal/cli/templates/sample_hooks/docker-setup.sample
256
266
  - lib/kamal/cli/templates/sample_hooks/post-app-boot.sample
267
+ - lib/kamal/cli/templates/sample_hooks/post-app-stop.sample
257
268
  - lib/kamal/cli/templates/sample_hooks/post-deploy.sample
269
+ - lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample
258
270
  - lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample
259
271
  - lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample
272
+ - lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample
260
273
  - lib/kamal/cli/templates/sample_hooks/pre-build.sample
261
274
  - lib/kamal/cli/templates/sample_hooks/pre-connect.sample
262
275
  - lib/kamal/cli/templates/sample_hooks/pre-deploy.sample
276
+ - lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample
263
277
  - lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample
264
278
  - lib/kamal/cli/templates/secrets
265
279
  - lib/kamal/commander.rb
@@ -318,10 +332,12 @@ files:
318
332
  - lib/kamal/configuration/logging.rb
319
333
  - lib/kamal/configuration/output.rb
320
334
  - lib/kamal/configuration/proxy.rb
335
+ - lib/kamal/configuration/proxy/acme.rb
321
336
  - lib/kamal/configuration/proxy/boot.rb
322
337
  - lib/kamal/configuration/proxy/run.rb
323
338
  - lib/kamal/configuration/registry.rb
324
339
  - lib/kamal/configuration/role.rb
340
+ - lib/kamal/configuration/role/healthcheck.rb
325
341
  - lib/kamal/configuration/servers.rb
326
342
  - lib/kamal/configuration/ssh.rb
327
343
  - lib/kamal/configuration/sshkit.rb
@@ -375,7 +391,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
375
391
  requirements:
376
392
  - - ">="
377
393
  - !ruby/object:Gem::Version
378
- version: '0'
394
+ version: '3.2'
379
395
  required_rubygems_version: !ruby/object:Gem::Requirement
380
396
  requirements:
381
397
  - - ">="