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.
- checksums.yaml +4 -4
- data/lib/kamal/cli/app/boot.rb +47 -11
- data/lib/kamal/cli/app/rollout_boot.rb +59 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +12 -3
- data/lib/kamal/cli/app.rb +74 -7
- data/lib/kamal/cli/base.rb +41 -32
- data/lib/kamal/cli/doctor/config_checks.rb +28 -0
- data/lib/kamal/cli/doctor/endpoint_checks.rb +114 -0
- data/lib/kamal/cli/doctor/host_checks.rb +178 -0
- data/lib/kamal/cli/doctor.rb +112 -0
- data/lib/kamal/cli/healthcheck/drift_error.rb +7 -0
- data/lib/kamal/cli/healthcheck/poller.rb +60 -10
- data/lib/kamal/cli/main.rb +63 -0
- data/lib/kamal/cli/proxy/drift.rb +39 -0
- data/lib/kamal/cli/proxy/loadbalancer_claim.rb +70 -0
- data/lib/kamal/cli/proxy/loadbalancer_reboot.rb +41 -0
- data/lib/kamal/cli/proxy/reboot.rb +172 -0
- data/lib/kamal/cli/proxy.rb +200 -26
- data/lib/kamal/cli/prune.rb +7 -4
- data/lib/kamal/cli/templates/sample_hooks/post-app-stop.sample +9 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample +12 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample +3 -0
- data/lib/kamal/cli.rb +1 -0
- data/lib/kamal/commands/app/proxy.rb +12 -0
- data/lib/kamal/commands/app.rb +8 -0
- data/lib/kamal/commands/base.rb +11 -1
- data/lib/kamal/commands/docker.rb +5 -0
- data/lib/kamal/commands/loadbalancer.rb +76 -34
- data/lib/kamal/commands/proxy.rb +105 -11
- data/lib/kamal/commands/prune.rb +16 -2
- data/lib/kamal/commands/server.rb +5 -0
- data/lib/kamal/configuration/accessory.rb +9 -8
- data/lib/kamal/configuration/boot.rb +38 -7
- data/lib/kamal/configuration/docs/accessory.yml +28 -1
- data/lib/kamal/configuration/docs/boot.yml +17 -2
- data/lib/kamal/configuration/docs/configuration.yml +2 -1
- data/lib/kamal/configuration/docs/proxy.yml +844 -24
- data/lib/kamal/configuration/docs/role.yml +86 -0
- data/lib/kamal/configuration/loadbalancer.rb +70 -7
- data/lib/kamal/configuration/proxy/acme.rb +69 -0
- data/lib/kamal/configuration/proxy/run.rb +194 -5
- data/lib/kamal/configuration/proxy.rb +495 -18
- data/lib/kamal/configuration/role/healthcheck.rb +95 -0
- data/lib/kamal/configuration/role.rb +104 -1
- data/lib/kamal/configuration/validator/proxy.rb +623 -10
- data/lib/kamal/configuration/validator/role.rb +26 -0
- data/lib/kamal/configuration/validator.rb +26 -3
- data/lib/kamal/configuration.rb +197 -0
- data/lib/kamal/sshkit_with_ext.rb +27 -2
- data/lib/kamal/utils.rb +22 -2
- data/lib/kamal/version.rb +1 -1
- data/lib/kamal.rb +11 -0
- 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)
|
data/lib/kamal/configuration.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
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:
|
|
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: '
|
|
394
|
+
version: '3.2'
|
|
379
395
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
380
396
|
requirements:
|
|
381
397
|
- - ">="
|