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
@@ -4,15 +4,178 @@ class Kamal::Configuration::Proxy
4
4
  DEFAULT_LOG_REQUEST_HEADERS = [ "Cache-Control", "Last-Modified", "User-Agent" ]
5
5
  CONTAINER_NAME = "kamal-proxy"
6
6
  LOADBALANCER_CONTAINER_NAME = "kamal-loadbalancer"
7
+ CLIENT_CA_FILENAME = "client-ca.pem"
7
8
 
8
- delegate :argumentize, :optionize, to: Kamal::Utils
9
+ # What `compress: true` offers. kamal-proxy has no "on" state without an
10
+ # explicit list - --compress *is* the list - so the shorthand has to pick.
11
+ # Best ratio first, matching the proxy's own default ordering; the client's
12
+ # Accept-Encoding q-values still outrank this preference.
13
+ DEFAULT_COMPRESSION_ENCODINGS = %w[ zstd br gzip ].freeze
14
+
15
+ SUPPORTED_COMPRESSION_ENCODINGS = %w[ gzip br zstd ].freeze
16
+
17
+ # kamal-proxy maps `brotli` onto the `br` token that travels in Content-Encoding.
18
+ COMPRESSION_ENCODING_ALIASES = { "brotli" => "br" }.freeze
19
+
20
+ # The layering contract. When the fork's load balancer fronts the per-host
21
+ # proxies, every deploy option lives at exactly one layer — or at both, on
22
+ # purpose. Nothing is allowed to be undecided: #deploy_options refuses to
23
+ # emit a key that has no disposition here, and test/proxy_layering_test.rb
24
+ # fails the build if a new option is added without one.
25
+ #
26
+ # :edge — only where clients connect. Stripped from the per-app deploy,
27
+ # applied by the load balancer.
28
+ # :per_app — only next to the app. Applied per-app, stripped from the
29
+ # load balancer.
30
+ # :both — each layer genuinely has its own copy of the concern.
31
+ #
32
+ # Without load balancing the single proxy is every layer at once and the
33
+ # whole surface applies to it.
34
+ DEPLOY_OPTION_DISPOSITIONS = {
35
+ # --- Edge: TLS terminates where the handshake happens, and kamal-proxy
36
+ # gates TLSRedirect on TLSEnabled, so the whole family travels together.
37
+ host: :edge,
38
+ tls: :edge,
39
+ "tls-staging": :edge,
40
+ "tls-certificate-path": :edge,
41
+ "tls-private-key-path": :edge,
42
+ "tls-redirect": :edge,
43
+ "tls-domains-source": :edge,
44
+ "tls-domains-interval": :edge,
45
+ "tls-domains-batch-size": :edge,
46
+ "tls-on-demand-url": :edge,
47
+ "tls-client-ca-path": :edge,
48
+
49
+ # --- Edge: the load balancer is the only proxy that ever sees the real
50
+ # client address — an allow list on a per-app proxy would refuse every
51
+ # request (its peer is the LB) and one limiter would count the whole
52
+ # fleet as a single client.
53
+ "allow-ip": :edge,
54
+ "trusted-proxy": :edge,
55
+ "client-ip-header": :edge,
56
+ "rate-limit": :edge,
57
+ "rate-limit-burst": :edge,
58
+ "rate-limit-exempt": :edge,
59
+
60
+ # --- Edge: kamal-proxy deletes the Authorization header once a service
61
+ # enforces basic auth, so an inner proxy would 401 the credential-less
62
+ # request the load balancer forwards. Credentials belong at the edge only.
63
+ "basic-auth": :edge,
64
+
65
+ # --- Edge: both layers used to pin with the same cookie name but separate
66
+ # HMAC keys, so the inner proxy clobbered the edge pin every other request.
67
+ # Only the edge pin can stick.
68
+ "session-affinity": :edge,
69
+ "session-affinity-cookie": :edge,
70
+
71
+ # --- Edge: redirectURLIfNeeded consults r.TLS only, so behind the LB a
72
+ # per-app redirect emits http:// Locations to HTTPS clients.
73
+ "canonical-host": :edge,
74
+ redirect: :edge,
75
+
76
+ # --- Edge: one response cache, at the edge — two layers of cache would
77
+ # double the storage and let the inner cache serve entries the edge
78
+ # already invalidated. The store it writes into is proxy-wide (proxy/run).
79
+ cache: :edge,
80
+ "cache-max-ttl": :edge,
81
+ "cache-max-body": :edge,
82
+ "cache-max-variants": :edge,
83
+ "cache-vary-header": :edge,
84
+ "cache-vary-cookie": :edge,
85
+ "cache-allow-set-cookie": :edge,
86
+
87
+ # --- Edge: splitting reads from writes is a fleet-level routing decision;
88
+ # per-app proxies each front a single host and have nothing to split.
89
+ "read-target": :edge,
90
+ "read-target-websockets": :edge,
91
+ "writer-affinity-timeout": :edge,
92
+
93
+ # --- Per-app: applied next to the app, exactly once. The LB forwards to
94
+ # the per-host proxies, so running these at both layers would add a header
95
+ # twice or run a rewrite over its own output.
96
+ "set-request-header": :per_app,
97
+ "add-request-header": :per_app,
98
+ "remove-request-header": :per_app,
99
+ "set-response-header": :per_app,
100
+ "add-response-header": :per_app,
101
+ "remove-response-header": :per_app,
102
+ rewrite: :per_app,
103
+ "intercept-errors": :per_app,
104
+
105
+ # --- Per-app: sleep stops and starts app containers through the docker
106
+ # socket — the LB has neither the socket nor the containers, and its
107
+ # targets are host addresses, so a sleep flag there fails the deploy.
108
+ "sleep-after": :per_app,
109
+ "wake-timeout": :per_app,
110
+ "sleep-container": :per_app,
111
+
112
+ # --- Per-app: compress once, next to the app. Double-running was only
113
+ # safe by accident of the Content-Encoding guard.
114
+ compress: :per_app,
115
+ "compress-content-type": :per_app,
116
+ "compress-min-length": :per_app,
117
+
118
+ # --- Both, deliberately: each layer has a real connection pool to its own
119
+ # targets (LB -> per-host proxies, per-host proxy -> app containers), so
120
+ # pool tuning and request deadlines apply to each hop.
121
+ "target-timeout": :both,
122
+ "target-max-conns": :both,
123
+ "target-max-idle-conns": :both,
124
+ "target-idle-conn-timeout": :both,
125
+ "target-dial-timeout": :both,
126
+ "target-disable-keep-alives": :both,
127
+ "target-try-duration": :both,
128
+ "target-try-interval": :both,
129
+ "path-timeout": :both,
130
+ "request-timeout": :both,
131
+ "path-request-timeout": :both,
132
+ "deploy-timeout": :both,
133
+ "drain-timeout": :both,
134
+
135
+ # --- Both: each layer health-checks its own targets, buffers its own
136
+ # connections, routes its own paths and writes its own logs.
137
+ "health-check-interval": :both,
138
+ "health-check-timeout": :both,
139
+ "health-check-path": :both,
140
+ "health-check-port": :both,
141
+ "health-check-host": :both,
142
+ "buffer-requests": :both,
143
+ "buffer-responses": :both,
144
+ "buffer-memory": :both,
145
+ "max-request-body": :both,
146
+ "max-response-body": :both,
147
+ "path-prefix": :both,
148
+ "strip-path-prefix": :both,
149
+ "forward-headers": :both,
150
+ "log-request-header": :both,
151
+ "log-response-header": :both,
152
+ "error-pages": :both,
153
+ "exclude-metrics-path": :both
154
+ }.freeze
155
+
156
+ # Refusing beats guessing: a deploy option nobody placed would silently land
157
+ # on both layers, which is how session affinity broke in the only topology
158
+ # where it matters.
159
+ def self.disposition(key)
160
+ DEPLOY_OPTION_DISPOSITIONS.fetch(key) do
161
+ raise Kamal::ConfigurationError,
162
+ "proxy deploy option --#{key} has no layering disposition - add it to Kamal::Configuration::Proxy::DEPLOY_OPTION_DISPOSITIONS"
163
+ end
164
+ end
165
+
166
+ delegate :argumentize, :optionize, :seconds_duration, to: Kamal::Utils
9
167
 
10
168
  attr_reader :config, :proxy_config, :role_name, :run, :secrets
11
- def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy")
169
+
170
+ # `load_balanced: false` marks a registration the fork's load balancer can
171
+ # never front - accessories, whose targets it does not collect. Such a proxy
172
+ # keeps its own host/TLS/basic-auth instead of deferring them to the edge.
173
+ def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy", load_balanced: true)
12
174
  @config = config
13
175
  @proxy_config = proxy_config
14
176
  @proxy_config = {} if @proxy_config.nil?
15
177
  @role_name = role_name
178
+ @load_balanced = load_balanced
16
179
  @secrets = secrets
17
180
  validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
18
181
  @run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"], context: "#{context}/run") if @proxy_config && @proxy_config["run"].present?
@@ -34,14 +197,25 @@ class Kamal::Configuration::Proxy
34
197
  proxy_config["loadbalancer"]
35
198
  end
36
199
 
200
+ # Root-level `proxy` setting only; ignored inside role-specific proxy blocks.
201
+ def reboot_on_deploy?
202
+ proxy_config.fetch("reboot_on_deploy", true)
203
+ end
204
+
37
205
  def load_balancing?
38
206
  effective_loadbalancer.present?
39
207
  end
40
208
 
209
+ def load_balanced?
210
+ @load_balanced
211
+ end
212
+
41
213
  def effective_loadbalancer
214
+ return nil unless load_balanced?
42
215
  return false if loadbalancer == false
216
+ return primary_role_first_host if loadbalancer == true
43
217
  return loadbalancer if loadbalancer.present?
44
- return config.primary_role.hosts.first if config.primary_role && Array(config.primary_role.hosts).size > 1
218
+ return primary_role_first_host if auto_load_balanced_primary_role?
45
219
 
46
220
  nil
47
221
  end
@@ -84,22 +258,87 @@ class Kamal::Configuration::Proxy
84
258
  tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate?
85
259
  end
86
260
 
261
+ # Everything TLS lives in the one `ssl` hash - certificate material,
262
+ # on-demand issuance and the mTLS client CA. One naming family instead of a
263
+ # separate `tls:` block.
264
+ def ssl_config
265
+ proxy_config["ssl"].is_a?(Hash) ? proxy_config["ssl"] : {}
266
+ end
267
+
268
+ def on_demand_url
269
+ ssl_config["on_demand_url"]
270
+ end
271
+
272
+ # The name of a secret in .kamal/secrets holding the CA bundle client
273
+ # certificates must chain to - mirroring ssl.certificate_pem, not a local
274
+ # file path. Kamal uploads the content into the app's TLS directory, which
275
+ # the proxy container already mounts, and hands the proxy the path it sees
276
+ # there.
277
+ def client_ca_pem
278
+ ssl_config["client_ca_pem"]
279
+ end
280
+
281
+ def client_ca?
282
+ client_ca_pem.present?
283
+ end
284
+
285
+ # Resolved at upload time, not config time, so `kamal app logs` and friends
286
+ # work on machines without the secret. A blank secret raises like
287
+ # basic_auth.password_secret - silently deploying without the client CA
288
+ # would turn mTLS off.
289
+ def client_ca_pem_content
290
+ secrets[client_ca_pem].tap do |content|
291
+ if content.blank?
292
+ raise Kamal::ConfigurationError, "proxy/ssl: client_ca_pem secret '#{client_ca_pem}' is empty"
293
+ end
294
+ end
295
+ end
296
+
297
+ def host_client_ca
298
+ tls_file_path(config.proxy_boot.tls_directory, CLIENT_CA_FILENAME) if client_ca?
299
+ end
300
+
301
+ def container_client_ca
302
+ tls_file_path(config.proxy_boot.tls_container_directory, CLIENT_CA_FILENAME) if client_ca?
303
+ end
304
+
87
305
  def path_prefixes
88
306
  proxy_config["path_prefixes"] || proxy_config["path_prefix"]&.split(",") || []
89
307
  end
90
308
 
309
+ # Nil when unset: the default lives in kamal-proxy, not here.
310
+ def healthcheck_path
311
+ proxy_config.dig("healthcheck", "path")
312
+ end
313
+
91
314
  def deploy_options
92
- opts = {
315
+ all_deploy_options.select { |key, _| retained_dispositions.include?(self.class.disposition(key)) }
316
+ end
317
+
318
+ # The full option surface before the layering contract is applied — what a
319
+ # single proxy (no load balancer) deploys with. Public so the layering canary
320
+ # can enumerate every key the gem emits.
321
+ def all_deploy_options
322
+ {
93
323
  host: hosts,
94
324
  tls: ssl? ? true : nil,
325
+ "tls-staging": proxy_config["ssl_staging"] ? true : nil,
95
326
  "tls-certificate-path": container_tls_cert,
96
327
  "tls-private-key-path": container_tls_key,
97
328
  "deploy-timeout": seconds_duration(config.deploy_timeout),
98
329
  "drain-timeout": seconds_duration(config.drain_timeout),
99
330
  "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
100
331
  "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")),
101
- "health-check-path": proxy_config.dig("healthcheck", "path"),
332
+ "health-check-path": healthcheck_path,
333
+ "health-check-port": proxy_config.dig("healthcheck", "port"),
334
+ "health-check-host": proxy_config.dig("healthcheck", "host"),
102
335
  "target-timeout": seconds_duration(proxy_config["response_timeout"]),
336
+ "read-target": proxy_config.dig("read_routing", "targets").presence,
337
+ "read-target-websockets": proxy_config.dig("read_routing", "websockets") ? true : nil,
338
+ "writer-affinity-timeout": seconds_duration(proxy_config.dig("read_routing", "writer_affinity_timeout")),
339
+ "path-timeout": path_timeout_args("path_response_timeouts"),
340
+ "request-timeout": seconds_duration(proxy_config["request_timeout"]),
341
+ "path-request-timeout": path_timeout_args("path_request_timeouts"),
103
342
  "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true),
104
343
  "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true),
105
344
  "buffer-memory": proxy_config.dig("buffering", "memory"),
@@ -109,23 +348,40 @@ class Kamal::Configuration::Proxy
109
348
  "strip-path-prefix": proxy_config.dig("strip_path_prefix"),
110
349
  "forward-headers": proxy_config.dig("forward_headers"),
111
350
  "tls-redirect": proxy_config.dig("ssl_redirect"),
351
+ "basic-auth": basic_auth_credential,
112
352
  "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
113
353
  "log-response-header": proxy_config.dig("logging", "response_headers"),
114
- "error-pages": error_pages
115
- }.compact
116
-
117
- if load_balancing?
118
- opts.delete(:host)
119
- opts.delete(:tls)
120
- end
121
-
122
- opts
354
+ "error-pages": error_pages,
355
+ # A deploy flag despite reading like metrics configuration: where the
356
+ # metrics are served and who may read them are proxy-wide and live under
357
+ # proxy/run, but which of *this service's* paths are counted is per service.
358
+ "exclude-metrics-path": proxy_config["exclude_metrics_paths"].presence
359
+ }.merge(ssl_domains_options).merge(tls_options).merge(cache_options).merge(compress_options)
360
+ .merge(access_control_options).merge(traffic_options).merge(lifecycle_options)
361
+ .merge(target_options).compact
123
362
  end
124
363
 
125
364
  def deploy_command_args(target:)
126
365
  optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "="
127
366
  end
128
367
 
368
+ # kamal-proxy rollout deploy only accepts the target and the timeouts - the service already
369
+ # exists, so it keeps the host, TLS, buffering and logging options of the live deploy.
370
+ def rollout_deploy_options
371
+ {
372
+ "deploy-timeout": seconds_duration(config.deploy_timeout),
373
+ "drain-timeout": seconds_duration(config.drain_timeout)
374
+ }.compact
375
+ end
376
+
377
+ def rollout_deploy_command_args(target:)
378
+ optionize ({ target: "#{target}:#{app_port}" }).merge(rollout_deploy_options), with: "="
379
+ end
380
+
381
+ def rollout_set_command_args(percent: nil, list: nil)
382
+ optionize({ percent: percent, list: list }.compact, with: "=")
383
+ end
384
+
129
385
  def stop_options(drain_timeout: nil, message: nil)
130
386
  {
131
387
  "drain-timeout": seconds_duration(drain_timeout),
@@ -138,16 +394,237 @@ class Kamal::Configuration::Proxy
138
394
  end
139
395
 
140
396
  def merge(other)
141
- self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets
397
+ self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets, load_balanced: load_balanced?
142
398
  end
143
399
 
144
400
  private
401
+ # Which dispositions this layer keeps. The per-app proxy behind a load
402
+ # balancer sheds the edge concerns; without load balancing there is no
403
+ # other layer to defer to. Kamal::Configuration::Loadbalancer overrides
404
+ # this to keep the edge and shed the per-app concerns.
405
+ def retained_dispositions
406
+ load_balancing? ? %i[ per_app both ] : %i[ edge per_app both ]
407
+ end
408
+
409
+ def primary_role_first_host
410
+ config.primary_role&.hosts&.first
411
+ end
412
+
413
+ # Auto-activation needs a role the load balancer can actually front. The
414
+ # target list is built from roles where `running_proxy?` (see
415
+ # Kamal::Cli::Proxy#loadbalancer), so a proxy-less primary role would boot a
416
+ # load balancer with an empty --target. An explicit `loadbalancer:` setting
417
+ # skips this check — the operator asked for it.
418
+ def auto_load_balanced_primary_role?
419
+ primary_role = config.primary_role
420
+
421
+ primary_role.present? && primary_role.running_proxy? && Array(primary_role.hosts).size > 1
422
+ end
423
+
424
+ # Flags for kamal-proxy's dynamic domain source (runtime TLS hostnames).
425
+ # TLS terminates wherever these flags land — :edge in the layering contract.
426
+ def ssl_domains_options
427
+ {
428
+ "tls-domains-source": proxy_config.dig("ssl_domains", "source"),
429
+ "tls-domains-interval": seconds_duration(proxy_config.dig("ssl_domains", "interval")),
430
+ "tls-domains-batch-size": proxy_config.dig("ssl_domains", "batch_size")
431
+ }.compact
432
+ end
433
+
434
+ # On-demand issuance and the mTLS client CA only matter where the
435
+ # handshake happens (:edge) - an ask endpoint or a client CA on a proxy
436
+ # that never terminates TLS would do nothing at all.
437
+ def tls_options
438
+ {
439
+ "tls-on-demand-url": on_demand_url,
440
+ "tls-client-ca-path": container_client_ca
441
+ }.compact
442
+ end
443
+
444
+ # The connection pool between the proxy and this app's containers, and how
445
+ # hard the proxy tries to place a request on a healthy one.
446
+ #
447
+ # Every value is passed through exactly as written. kamal-proxy resolves its
448
+ # own defaults from a zero (target_pool.go), and it has to do that server
449
+ # side because restored state and older RPC clients never see the CLI — so
450
+ # substituting a default here would be both redundant and wrong.
451
+ def target_options
452
+ target = proxy_config["target"] || {}
453
+
454
+ {
455
+ "target-max-conns": target["max_conns"],
456
+ "target-max-idle-conns": target["max_idle_conns"],
457
+ "target-idle-conn-timeout": seconds_duration(target["idle_conn_timeout"]),
458
+ "target-dial-timeout": seconds_duration(target["dial_timeout"]),
459
+ "target-disable-keep-alives": target["disable_keep_alives"] ? true : nil,
460
+ "target-try-duration": seconds_duration(target["try_duration"]),
461
+ "target-try-interval": seconds_duration(target["try_interval"])
462
+ }.compact
463
+ end
464
+
465
+ # Session affinity and scale-to-zero: which target a client keeps, and
466
+ # whether the targets are running at all. Not one layer: affinity is :edge
467
+ # (an inner pin would clobber the edge pin), sleep is :per_app (only the
468
+ # app hosts have the docker socket and the containers).
469
+ #
470
+ # Sleep needs the container runtime socket, which is a run-level setting -
471
+ # Kamal::Configuration#ensure_sleep_has_a_docker_socket refuses the pairing
472
+ # rather than letting the first request after an idle period hang.
473
+ def lifecycle_options
474
+ affinity = proxy_config["session_affinity"] || {}
475
+ sleep_config = proxy_config["sleep"] || {}
476
+ affinity_enabled = affinity["enabled"] ? true : nil
477
+
478
+ {
479
+ "session-affinity": affinity_enabled,
480
+ "session-affinity-cookie": (affinity["cookie"] if affinity_enabled),
481
+ "sleep-after": seconds_duration(sleep_config["after"]),
482
+ "wake-timeout": seconds_duration(sleep_config["wake_timeout"]),
483
+ "sleep-container": sleep_config["containers"].presence
484
+ }.compact
485
+ end
486
+
487
+ # Header rewriting, redirects, rewrites and error interception. Not one
488
+ # layer: headers/rewrites/error interception are :per_app (the LB would
489
+ # append an `add` header twice and run a rewrite over its own output),
490
+ # while canonical-host and redirect are :edge (they consult r.TLS, so
491
+ # behind the LB they would emit http:// Locations to HTTPS clients).
492
+ def traffic_options
493
+ {
494
+ "set-request-header": header_rules("request", "set"),
495
+ "add-request-header": header_rules("request", "add"),
496
+ "remove-request-header": headers_config.dig("request", "remove").presence,
497
+ "set-response-header": header_rules("response", "set"),
498
+ "add-response-header": header_rules("response", "add"),
499
+ "remove-response-header": headers_config.dig("response", "remove").presence,
500
+ redirect: path_rules("redirects"),
501
+ rewrite: path_rules("rewrites"),
502
+ "canonical-host": proxy_config["canonical_host"],
503
+ "intercept-errors": proxy_config["intercept_errors"].presence
504
+ }.compact
505
+ end
506
+
507
+ def headers_config
508
+ proxy_config["headers"] || {}
509
+ end
510
+
511
+ # kamal-proxy cuts a rule at the first colon, so a value carrying colons of
512
+ # its own arrives intact.
513
+ def header_rules(direction, verb)
514
+ (headers_config.dig(direction, verb) || {}).map { |name, value| "#{name}: #{value}" }.presence
515
+ end
516
+
517
+ # '<pattern>=<replacement>', with ';status=<code>' appended for a redirect
518
+ # that asks for one. Cut at the first '=' on the proxy side.
519
+ def path_rules(key)
520
+ Array(proxy_config[key]).map do |rule|
521
+ "#{rule["from"]}=#{rule["to"]}#{";status=#{rule["status"]}" if rule["status"]}"
522
+ end.presence
523
+ end
524
+
525
+ # Rate limiting, the IP allow list, and the client-IP identification both of
526
+ # them key on. :edge - the per-host proxy's peer is the load balancer, so an
527
+ # allow list there would refuse every request and one limiter would count
528
+ # the whole fleet as a single client.
529
+ def access_control_options
530
+ client_ip = proxy_config["client_ip"] || {}
531
+ rate_limit = proxy_config["rate_limit"] || {}
532
+
533
+ {
534
+ "allow-ip": proxy_config["allow_ips"].presence,
535
+ "trusted-proxy": client_ip["trusted_proxies"].presence,
536
+ "client-ip-header": client_ip["header"],
537
+ "rate-limit": rate_limit["requests"],
538
+ "rate-limit-burst": rate_limit["burst"],
539
+ "rate-limit-exempt": rate_limit["exempt"].presence
540
+ }.compact
541
+ end
542
+
543
+ # `compress: true` and the block form both land here. --compress is a list of
544
+ # encodings rather than a switch, so "on" always means naming them: a bare
545
+ # --compress would take the next flag on the command line as its value.
546
+ #
547
+ # An explicit `enabled: false` wins over encodings implying "on" - it is
548
+ # the off switch for a block whose tuning the operator wants to keep.
549
+ def compress_options
550
+ compress = proxy_config["compress"]
551
+ settings = compress.is_a?(Hash) ? compress : {}
552
+ return {} if settings["enabled"] == false
553
+ return {} unless compress == true || settings["enabled"] || settings["encodings"].present?
554
+
555
+ {
556
+ compress: settings["encodings"].presence || DEFAULT_COMPRESSION_ENCODINGS,
557
+ "compress-content-type": settings["content_types"].presence,
558
+ "compress-min-length": settings["min_length"]
559
+ }.compact
560
+ end
561
+
562
+ # The cache policy, which is per service - the store it writes into is
563
+ # proxy-wide and lives in proxy/run/cache. Only --cache is derived from a
564
+ # truthy key; every other default stays in kamal-proxy, so an unset key emits
565
+ # nothing rather than restating a default the gem would then have to track.
566
+ def cache_options
567
+ cache = proxy_config["cache"] || {}
568
+
569
+ {
570
+ cache: cache["enabled"] ? true : nil,
571
+ "cache-max-ttl": seconds_duration(cache["max_ttl"]),
572
+ "cache-max-body": cache["max_body"],
573
+ "cache-max-variants": cache["max_variants"],
574
+ "cache-vary-header": cache["vary_headers"].presence,
575
+ "cache-vary-cookie": cache["vary_cookies"].presence,
576
+ "cache-allow-set-cookie": cache["allow_set_cookie"] ? true : nil
577
+ }.compact
578
+ end
579
+
145
580
  def tls_path(directory, filename)
146
- File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
581
+ tls_file_path(directory, filename) if custom_ssl_certificate?
582
+ end
583
+
584
+ # The path construction on its own: a client CA bundle lives beside the
585
+ # server certificate but has nothing to do with whether one was configured.
586
+ def tls_file_path(directory, filename)
587
+ File.join([ directory, role_name, filename ].compact)
588
+ end
589
+
590
+ # kamal-proxy takes the credential as <username>:<password> and cuts at the
591
+ # first colon, so a password may contain colons but a username may not (the
592
+ # validator enforces that). The password is read from .kamal/secrets when
593
+ # password_secret names it, so it need not live in deploy.yml.
594
+ def basic_auth_credential
595
+ basic_auth = proxy_config["basic_auth"]
596
+ return nil unless basic_auth.is_a?(Hash)
597
+
598
+ password =
599
+ if (secret_name = basic_auth["password_secret"]).present?
600
+ secrets[secret_name]
601
+ else
602
+ basic_auth["password"]
603
+ end
604
+
605
+ # The validator guarantees a password was configured, so a blank one here
606
+ # means the named secret resolved empty. Fail rather than drop the flag —
607
+ # silently deploying an unprotected service is the worse outcome.
608
+ if password.blank?
609
+ raise Kamal::ConfigurationError, "proxy/basic_auth: password_secret '#{secret_name}' is empty"
610
+ end
611
+
612
+ # Sensitive, so SSHKit redacts the credential wherever kamal prints the
613
+ # deploy command - at :info verbosity it used to land in plain text
614
+ # (registry-login precedent; Utils.optionize passes the marking through).
615
+ Kamal::Utils.sensitive("#{basic_auth["username"]}:#{password}")
147
616
  end
148
617
 
149
- def seconds_duration(value)
150
- value ? "#{value}s" : nil
618
+ # Serves both --path-timeout and --path-request-timeout, which kamal-proxy
619
+ # reads with one parser. A duration may be a Go string ("5m") or plain
620
+ # seconds; 0 is a value, meaning no limit for that prefix.
621
+ def path_timeout_args(key)
622
+ if (timeouts = proxy_config[key]).present?
623
+ timeouts.map do |prefix, duration|
624
+ duration = seconds_duration(duration) if duration.is_a?(Numeric)
625
+ "#{prefix}=#{duration}"
626
+ end
627
+ end
151
628
  end
152
629
 
153
630
  def error_pages
@@ -0,0 +1,95 @@
1
+ class Kamal::Configuration::Role::Healthcheck
2
+ DEFAULT_PATH = "/up"
3
+ DEFAULT_INTERVAL = 1
4
+
5
+ # Everything that only ever reaches docker as a `--health-*` flag. An exec probe emits
6
+ # none of those, so combining them would silently drop whatever the operator wrote.
7
+ DOCKER_ONLY_KEYS = %w[ cmd port path interval timeout retries start_period start_interval ]
8
+
9
+ attr_reader :healthcheck_config, :context
10
+ delegate :optionize, to: Kamal::Utils
11
+
12
+ def initialize(healthcheck_config:, context: "healthcheck")
13
+ @healthcheck_config = healthcheck_config
14
+ @context = context
15
+ validate!
16
+ end
17
+
18
+ # The command docker runs inside the container. Custom commands win; otherwise
19
+ # we build the same curl check Kamal 1 shipped.
20
+ def cmd
21
+ healthcheck_config["cmd"] || http_health_check
22
+ end
23
+
24
+ # The probe kamal `docker exec`s from the deploy host during a boot, for images whose
25
+ # HEALTHCHECK cannot be changed. Deploy-time only: docker never runs it, so `docker ps`
26
+ # shows no `(healthy)` and `docker inspect` keeps no probe history.
27
+ def exec
28
+ healthcheck_config["exec"]
29
+ end
30
+
31
+ def exec?
32
+ exec.present?
33
+ end
34
+
35
+ def port
36
+ healthcheck_config["port"]
37
+ end
38
+
39
+ def path
40
+ healthcheck_config.fetch("path", DEFAULT_PATH)
41
+ end
42
+
43
+ def interval
44
+ healthcheck_config.fetch("interval", DEFAULT_INTERVAL)
45
+ end
46
+
47
+ def args
48
+ return [] if exec?
49
+
50
+ optionize({
51
+ "health-cmd" => cmd,
52
+ "health-interval" => duration(interval),
53
+ "health-timeout" => duration(healthcheck_config["timeout"]),
54
+ "health-retries" => healthcheck_config["retries"],
55
+ "health-start-period" => duration(healthcheck_config["start_period"]),
56
+ "health-start-interval" => duration(healthcheck_config["start_interval"])
57
+ }.compact)
58
+ end
59
+
60
+ private
61
+ def http_health_check
62
+ "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1"
63
+ end
64
+
65
+ # Bare numbers are seconds, anything else is passed to docker as written.
66
+ def duration(value)
67
+ case value
68
+ when nil then nil
69
+ when /\A\d+\z/, Integer then "#{value}s"
70
+ else value.to_s
71
+ end
72
+ end
73
+
74
+ def validate!
75
+ if exec? && (conflicting = DOCKER_ONLY_KEYS.find { |key| healthcheck_config[key].present? })
76
+ error "cannot be combined with #{conflicting}, which only configures docker's own healthcheck " \
77
+ "— an exec probe is polled from the deploy host", context: "exec"
78
+ end
79
+
80
+ if healthcheck_config["cmd"].blank? && !exec? && port.blank?
81
+ error "port is required unless cmd or exec is set"
82
+ end
83
+
84
+ # Utils#escape_shell_value leaves ${...} alone, so it would expand in the
85
+ # deploy host's shell when docker run is assembled. `exec` has no such problem:
86
+ # Commands::App#health_probe single-quotes it, so it expands in the container.
87
+ if healthcheck_config["cmd"].to_s.include?("${")
88
+ error "cannot contain ${...}, it would expand on the deploy host, not in the container", context: "cmd"
89
+ end
90
+ end
91
+
92
+ def error(message, context: nil)
93
+ raise Kamal::ConfigurationError, "#{[ @context, context ].compact.join("/")}: #{message}"
94
+ end
95
+ end