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
@@ -40,6 +40,37 @@ servers:
40
40
  - 172.1.0.4: experiment1
41
41
  cmd: "bin/jobs"
42
42
  stop_timeout: 30
43
+
44
+ # How long this role's container must keep running before the deploy accepts it,
45
+ # when there is no healthcheck to wait on. Overrides the root `readiness_delay`
46
+ # for this role only; roles with a healthcheck never wait on it.
47
+ readiness_delay: 4
48
+
49
+ # Rolling boot
50
+ #
51
+ # The root `boot` key paces the deploy across the whole cross-role host list, so it
52
+ # cannot serialize one role's hosts while another role keeps booting in parallel.
53
+ # Set `boot` on the role instead and its hosts are paced on their own — a second
54
+ # host in a worker role finally buys the redundancy it looks like it buys.
55
+ #
56
+ # `limit` is a count, or a percentage of the hosts this run is booting for the role
57
+ # (so `--roles` / `--hosts` narrow the denominator too); `limit: 1` boots them one at
58
+ # a time. `wait` is the pause between hosts or groups, and defaults to none. It needs
59
+ # `limit` to do anything — without one the role's hosts boot in a single group, leaving
60
+ # no gap to pause in, and Kamal warns. Nothing waits after the final group.
61
+ #
62
+ # A role using this key needs the role-first iteration of `boot/parallel_roles`.
63
+ # Kamal turns it on for you when you have not set it; an explicit
64
+ # `parallel_roles: false` is a conflict and raises instead.
65
+ boot:
66
+ limit: 25%
67
+ wait: 10
68
+
69
+ # Option values are shell-escaped before reaching `docker run`, but `${...}` is
70
+ # deliberately left for the deploy host's shell to expand. Role `env` is never
71
+ # defined in that shell, so `${MY_VAR}` resolves to empty. Use the bare `$MY_VAR`
72
+ # form, which is escaped and expands inside the container. Kamal rejects `${...}`
73
+ # in `health-*` options, where the empty value would only surface as a deploy timeout.
43
74
  options:
44
75
  memory: 2g
45
76
  cpus: 4
@@ -52,3 +83,58 @@ servers:
52
83
  env:
53
84
  ...
54
85
  asset_path: /public
86
+
87
+ # Healthcheck
88
+ #
89
+ # A role that does not run the proxy has no readiness gate of its own: the
90
+ # deploy only checks that the container is still running after
91
+ # `readiness_delay` seconds before stopping the old one. Declare a
92
+ # `healthcheck` and Kamal renders it into docker's own HEALTHCHECK flags, so
93
+ # the deploy waits for the container to report healthy instead.
94
+ #
95
+ # `port` is required unless you set `cmd`. The default HTTP check is
96
+ # `curl -f http://localhost:<port><path> || exit 1`, so the image needs curl.
97
+ #
98
+ # `cmd` runs inside the container via /bin/sh and can be anything that exits
99
+ # non-zero when unhealthy — no HTTP server and no published port needed. It
100
+ # may not contain `${...}`: that would expand on the deploy host, not in the
101
+ # container.
102
+ #
103
+ # Durations are seconds when given as a plain number, or any docker duration
104
+ # string ("90s", "1m30s"). `interval` defaults to 1 second so a deploy is not
105
+ # left waiting on docker's own 30 second default.
106
+ #
107
+ # A `healthcheck` cannot be combined with `health-*` keys under `options`.
108
+ #
109
+ # `exec` is the escape hatch for an image whose HEALTHCHECK you cannot change,
110
+ # or for an emergency override without a rebuild. Kamal `docker exec`s it from
111
+ # the deploy host on every poll and gates the deploy on the exit code — no HTTP
112
+ # server and no published port needed, and unlike `cmd` it may use `${...}`,
113
+ # which is quoted through to the container. It is strictly worse than `cmd` in
114
+ # the general case, so reach for it only when `cmd` is not available:
115
+ #
116
+ # - deploy-time only. Docker never runs it, so `docker ps` never shows
117
+ # `(healthy)` and `docker inspect` keeps no probe history.
118
+ # - each poll costs an SSH round trip plus a process spawn (~100-300ms), which
119
+ # rules out sub-second polling.
120
+ # - nothing outside a deploy ever runs it.
121
+ #
122
+ # `exec` replaces docker's healthcheck rather than configuring it, so it cannot
123
+ # be combined with `cmd`, `port`, `path`, or any of the duration keys. Polling
124
+ # follows the deploy's own backoff and gives up at `deploy_timeout`; a probe that
125
+ # never exits zero fails the boot and leaves the old container running.
126
+ #
127
+ # A non-proxied role with neither a `healthcheck` nor a `health-cmd` option
128
+ # warns on every deploy, because the readiness delay is the only thing standing
129
+ # between a container that starts and a container that serves. Write
130
+ # `healthcheck: false` to accept that gap explicitly and silence the warning.
131
+ healthcheck:
132
+ port: 7434
133
+ path: /readyz
134
+ cmd: "pgrep -f bin/jobs"
135
+ exec: "bin/ready-check"
136
+ interval: 5
137
+ timeout: 3
138
+ retries: 3
139
+ start_period: 60
140
+ start_interval: 1
@@ -10,19 +10,73 @@ class Kamal::Configuration::Loadbalancer < Kamal::Configuration::Proxy
10
10
  super
11
11
  end
12
12
 
13
- def deploy_options
14
- opts = super
15
-
16
- opts[:host] = hosts if hosts.present?
17
- opts[:tls] = proxy_config["ssl"].presence
18
-
19
- opts
13
+ # The load balancer fans a single service out to many targets, so unlike the
14
+ # per-app proxy deploy (which takes one target) it takes the full list and
15
+ # joins them into a single --target flag, honouring app_port for each.
16
+ def deploy_command_args(targets:)
17
+ target_arg = targets.map { |target| "#{target}:#{app_port}" }.join(",")
18
+ optionize ({ target: target_arg }).merge(deploy_options), with: "="
20
19
  end
21
20
 
22
21
  def directory
23
22
  File.join config.run_directory, "loadbalancer"
24
23
  end
25
24
 
25
+ # The load balancer is a kamal-proxy container, so proxy/run applies to it
26
+ # exactly as it does to the per-host proxies. Without a run block it still
27
+ # needs the default surface (published ports, log rotation, the apps-config
28
+ # mount and the run command) to boot from, so fall back to an empty config
29
+ # rather than to nil.
30
+ def run
31
+ @run ||= Kamal::Configuration::Proxy::Run.new(config, run_config: {})
32
+ end
33
+
34
+ # Docker options for the load balancer container: publish/logging/options
35
+ # plus the mounts and env file the proxy's run surface brings along —
36
+ # notably the secrets env file, without which the edge cannot issue
37
+ # certificates even though it is the layer terminating TLS.
38
+ #
39
+ # The one holder-mode exception: port_holder suppresses publish_args on the
40
+ # proxy hosts (the holder owns the ports there), but the load balancer has
41
+ # no holder — its reboot path is stop->run — so it always publishes its own.
42
+ def run_args
43
+ [ *(run.publish_args if run.port_holder?), *run.docker_options_args ]
44
+ end
45
+
46
+ # Digest of what the load balancer container is booted with, so a second app
47
+ # pointing at the same host can tell whether its proxy/run agrees with
48
+ # whatever is already running there. Same composition as the proxy's own
49
+ # drift digest: image, run command, docker options and the acme credential
50
+ # names (their values deliberately stay out - see Proxy::Run#config_digest).
51
+ def run_config_digest
52
+ run.config_digest
53
+ end
54
+
55
+ # Kamal has no app identifier, so ownership of a service on a shared load
56
+ # balancer is expressed as repository plus destination-qualified service name:
57
+ # the repository separates two different apps, the destination separates two
58
+ # deployments of one app. The load balancer registers services under the bare
59
+ # service name, so both can collide.
60
+ def owner_token
61
+ [ config.service_and_destination, config.repository ].join(" ")
62
+ end
63
+
64
+ def run_config_record
65
+ [ owner_token, run_config_digest ].join(" ")
66
+ end
67
+
68
+ def run_config_file
69
+ File.join directory, "run_config"
70
+ end
71
+
72
+ def services_directory
73
+ File.join directory, "services"
74
+ end
75
+
76
+ def service_owner_file
77
+ File.join services_directory, config.service
78
+ end
79
+
26
80
  def container_name
27
81
  on_proxy_host? ? SHARED_CONTAINER_NAME : CONTAINER_NAME
28
82
  end
@@ -31,4 +85,13 @@ class Kamal::Configuration::Loadbalancer < Kamal::Configuration::Proxy
31
85
  def on_proxy_host?
32
86
  config.proxy_hosts.include?(config.proxy.effective_loadbalancer)
33
87
  end
88
+
89
+ private
90
+ # The edge half of the layering contract (see
91
+ # Kamal::Configuration::Proxy::DEPLOY_OPTION_DISPOSITIONS): the load
92
+ # balancer applies the edge and shared concerns, and leaves the per-app
93
+ # ones to the per-host proxies it forwards to.
94
+ def retained_dispositions
95
+ %i[ edge both ]
96
+ end
34
97
  end
@@ -0,0 +1,69 @@
1
+ # ACME configuration for the kamal-proxy container, including the DNS-01
2
+ # challenge credentials.
3
+ #
4
+ # Everything here except the credentials becomes a `kamal-proxy run` flag, so it
5
+ # lands in the run command and therefore in Proxy::Run#config_digest — changing
6
+ # the block reboots the proxy on the next deploy.
7
+ #
8
+ # Credentials go the other way, through an env file uploaded at mode 0600. A DNS
9
+ # API token can rewrite your zone; `docker run --env TOKEN=...` would put it in
10
+ # the host's process listing and in kamal's own audit log.
11
+ class Kamal::Configuration::Proxy::Acme
12
+ # The canonical provider names kamal-proxy MINIMUM_VERSION advertises. Kept in
13
+ # step with the proxy by test/proxy_flag_coverage_test.rb, which compares this
14
+ # list against the manifest bin/sync-proxy-flags generates from the image.
15
+ DNS_PROVIDERS = %w[
16
+ auto cloudflare digitalocean gcloud godaddy hetzner namecheap route53 vultr
17
+ ].freeze
18
+
19
+ # Short forms kamal-proxy's ParseProviderName accepts but does not advertise,
20
+ # so they cannot be generated from --help and are not drift-checked.
21
+ DNS_PROVIDER_ALIASES = %w[ cf do gcp google googledns gd hz nc aws r53 vr ].freeze
22
+
23
+ SUPPORTED_DNS_PROVIDERS = (DNS_PROVIDERS + DNS_PROVIDER_ALIASES).freeze
24
+
25
+ attr_reader :acme_config, :secrets
26
+
27
+ def initialize(acme_config:, secrets:)
28
+ @acme_config = acme_config || {}
29
+ @secrets = secrets
30
+ end
31
+
32
+ def configured?
33
+ acme_config.present?
34
+ end
35
+
36
+ def credential_names
37
+ Array(acme_config["credentials"])
38
+ end
39
+
40
+ def credentials?
41
+ credential_names.any?
42
+ end
43
+
44
+ # The resolved credentials, for the shared proxy secrets env file that
45
+ # Proxy::Run#secrets_io assembles (cache store and acme travel together).
46
+ def credentials_env
47
+ credential_names.to_h { |name| [ name, secrets[name] ] }
48
+ end
49
+
50
+ # Rendered with `=` rather than a space, unlike the rest of the run command:
51
+ # Cobra only reads a boolean flag's value in --flag=false form, so
52
+ # `--acme-http-fallback false` would set the flag true and leave "false" behind
53
+ # as a stray argument. Both booleans default to true in the proxy, which makes
54
+ # false the value an operator actually writes.
55
+ def run_command_args
56
+ Kamal::Utils.optionize(run_command_options, with: "=")
57
+ end
58
+
59
+ # compact, not compact_blank: false is a meaningful value for both booleans.
60
+ def run_command_options
61
+ {
62
+ "acme-email": acme_config["email"],
63
+ "acme-dns-provider": acme_config["dns_provider"],
64
+ "acme-directory": acme_config["directory"],
65
+ "acme-prefer-wildcard": acme_config["prefer_wildcard"],
66
+ "acme-http-fallback": acme_config["http_fallback"]
67
+ }.compact
68
+ end
69
+ end
@@ -1,16 +1,49 @@
1
1
  class Kamal::Configuration::Proxy::Run
2
- MINIMUM_VERSION = "v0.9.2.1"
2
+ MINIMUM_VERSION = "v1.0.0.1"
3
3
  DEFAULT_HTTP_PORT = 80
4
4
  DEFAULT_HTTPS_PORT = 443
5
5
  DEFAULT_LOG_MAX_SIZE = "10m"
6
6
 
7
+ # One env file for everything the proxy must know but nothing may print:
8
+ # ACME DNS credentials and the cache store URL. Also referenced by
9
+ # Kamal::Commands::Proxy#remove_proxy_secrets_file, which cleans it up when
10
+ # the config no longer needs it.
11
+ SECRETS_FILENAME = "secrets.env"
12
+
13
+ # Bump when the digest serialization changes, so every host converges with
14
+ # exactly one reboot after upgrading kamal.
15
+ DIGEST_SCHEMA_VERSION = "v1"
16
+
7
17
  attr_reader :config, :run_config
8
- delegate :argumentize, :optionize, to: Kamal::Utils
18
+ delegate :argumentize, :optionize, :seconds_duration, to: Kamal::Utils
9
19
 
10
20
  def initialize(config, run_config:, context: "proxy/run")
11
21
  @config = config
12
22
  @run_config = run_config
13
23
  @context = context
24
+ ensure_no_conflicting_flags
25
+ end
26
+
27
+ def self.digest(*parts)
28
+ Digest::SHA256.hexdigest([ DIGEST_SCHEMA_VERSION, *parts ].join("\n"))
29
+ end
30
+
31
+ # Digest of the materialized run invocation, used to detect drift between
32
+ # the running proxy container and the current configuration.
33
+ #
34
+ # The secret *names* ride along because --env-file names a path, not the
35
+ # variables inside it: swapping one credential for another (or adding the
36
+ # cache store) would otherwise leave the digest unmoved and the old proxy
37
+ # running. The values deliberately stay out — the digest is published as a
38
+ # docker label, and hashing secret material into a world-readable label buys
39
+ # an offline guessing target for nothing. Rotating a credential's value or
40
+ # the store URL still needs an explicit `kamal proxy reboot`.
41
+ def config_digest
42
+ self.class.digest(image, run_command, *docker_options_args, *secret_names)
43
+ end
44
+
45
+ def acme
46
+ @acme ||= Kamal::Configuration::Proxy::Acme.new(acme_config: run_config["acme"], secrets: config.secrets)
14
47
  end
15
48
 
16
49
  def debug?
@@ -73,6 +106,29 @@ class Kamal::Configuration::Proxy::Run
73
106
  "kamal-proxy"
74
107
  end
75
108
 
109
+ # Zero-downtime reboots: a minimal long-lived holder container owns the
110
+ # published ports, and proxy generations join its network namespace so two
111
+ # can overlap on the same ports during a handoff.
112
+ def port_holder?
113
+ run_config.fetch("port_holder", false)
114
+ end
115
+
116
+ def holder_container_name
117
+ "kamal-proxy-net"
118
+ end
119
+
120
+ def network_args
121
+ if port_holder?
122
+ [ "--network", "container:#{holder_container_name}" ]
123
+ else
124
+ [ "--network", "kamal" ]
125
+ end
126
+ end
127
+
128
+ def holder_docker_args
129
+ [ *publish_args, *logging_args ].compact
130
+ end
131
+
76
132
  def options_args
77
133
  if args = run_config["options"]
78
134
  optionize args
@@ -80,7 +136,7 @@ class Kamal::Configuration::Proxy::Run
80
136
  end
81
137
 
82
138
  def run_command
83
- [ "kamal-proxy", "run", *optionize(run_command_options) ].join(" ")
139
+ [ "kamal-proxy", "run", *optionize(run_command_options), *acme.run_command_args ].join(" ")
84
140
  end
85
141
 
86
142
  def metrics_port
@@ -88,19 +144,77 @@ class Kamal::Configuration::Proxy::Run
88
144
  end
89
145
 
90
146
  def run_command_options
91
- { debug: debug? || nil, "metrics-port": metrics_port }.compact
147
+ named_run_command_options.merge(flags)
148
+ end
149
+
150
+ # Everything the gem has a key for, as opposed to whatever `flags` passes
151
+ # through. Kept separate so the two can be checked against each other.
152
+ def named_run_command_options
153
+ # recheck-targets-on-restore: after a reboot, re-verify restored targets
154
+ # with live health checks instead of trusting the saved state — a dead
155
+ # target demotes to 503 and self-heals rather than serving 502s forever.
156
+ # Available from MINIMUM_VERSION, so it is always safe to pass.
157
+ { debug: debug? || nil, "metrics-port": metrics_port, "recheck-targets-on-restore": true, "docker-socket": docker_socket }
158
+ .compact.merge(cache_options).merge(server_options)
159
+ end
160
+
161
+ # Anything kamal-proxy accepts that has no key of its own yet. Values are
162
+ # optionized as written - `true` is a bare flag, anything else takes a value -
163
+ # because the point of an escape hatch is that the gem holds no opinion about
164
+ # the flag it is forwarding.
165
+ #
166
+ # Note the asymmetry with `options`, which is a `docker run` passthrough. The
167
+ # names are deliberately different; most of why this gap went unnoticed is
168
+ # that `options` reads like it should do this.
169
+ def flags
170
+ (run_config["flags"] || {}).transform_keys(&:to_sym)
171
+ end
172
+
173
+ # What proxy/sleep needs in order to stop and start containers. Reaching this
174
+ # socket is root-equivalent on the host, so nothing turns it on implicitly -
175
+ # an operator has to name it, and naming it is also what mounts it (below).
176
+ def docker_socket
177
+ run_config["docker_socket"]
92
178
  end
93
179
 
94
180
  def docker_options_args
95
181
  [
96
182
  *apps_volume_args,
97
- *publish_args,
183
+ *(publish_args unless port_holder?),
98
184
  *logging_args,
99
185
  *("--expose=#{metrics_port}" if metrics_port.present?),
186
+ *secrets_args,
187
+ *docker_socket_args,
100
188
  *options_args
101
189
  ].compact
102
190
  end
103
191
 
192
+ # The flag only tells kamal-proxy where to look inside its own container, so
193
+ # without this mount the socket is not there and a sleeping service never
194
+ # wakes - which the operator sees as one hung request, not as a misconfiguration.
195
+ def docker_socket_args
196
+ if docker_socket.present?
197
+ Kamal::Configuration::Volume.new(host_path: docker_socket, container_path: docker_socket).docker_args
198
+ end
199
+ end
200
+
201
+ # Where the proxy's secrets land on the host - the ACME DNS credentials and
202
+ # the cache store URL, which may embed one. Under the proxy's own directory
203
+ # rather than the app's env directory, because the container is host-scoped
204
+ # and shared by every app on the host - and so `kamal proxy remove` takes
205
+ # the secrets with it.
206
+ def secrets_path
207
+ File.join host_directory, SECRETS_FILENAME
208
+ end
209
+
210
+ def secrets?
211
+ acme.credentials? || cache_store.present?
212
+ end
213
+
214
+ def secrets_io
215
+ Kamal::EnvFile.new(acme.credentials_env.merge(cache_store_env)).to_io
216
+ end
217
+
104
218
  def host_directory
105
219
  File.join config.run_directory, "proxy"
106
220
  end
@@ -140,7 +254,82 @@ class Kamal::Configuration::Proxy::Run
140
254
  run_config.hash
141
255
  end
142
256
 
257
+ # Whether an operator turned on PROXY protocol without saying who may speak
258
+ # it. Kamal::Configuration warns about this: the header rewrites the
259
+ # connecting address that allow_ips and rate_limit key on, so honouring it
260
+ # from anywhere is a client-IP spoofing hole.
261
+ def proxy_protocol_unrestricted?
262
+ run_config["proxy_protocol"] && Array(run_config["proxy_protocol_allow_ips"]).empty?
263
+ end
264
+
143
265
  private
266
+ # The proxy's own listeners and process-level behaviour, as opposed to the
267
+ # per-service deadlines in Kamal::Configuration::Proxy#deploy_options. Zero
268
+ # disables each of the timeouts, so it is a value rather than an absence.
269
+ def server_options
270
+ {
271
+ "log-format": run_config["log_format"],
272
+ "trace-context": run_config["trace_context"],
273
+ "min-tls": run_config["min_tls"]&.to_s,
274
+ http3: run_config["http3"] ? true : nil,
275
+ # port_holder needs two generations bound to :80/:443 at once, so the
276
+ # holder topology forces the flag regardless of the standalone key.
277
+ "reuse-port": (run_config["reuse_port"] || port_holder?) ? true : nil,
278
+ "ignore-restore-errors": run_config["ignore_restore_errors"] ? true : nil,
279
+ "proxy-protocol": run_config["proxy_protocol"] ? true : nil,
280
+ "proxy-protocol-allow-ip": run_config["proxy_protocol_allow_ips"].presence,
281
+ "metrics-allow-ip": run_config["metrics_allow_ips"].presence,
282
+ "read-header-timeout": seconds_duration(run_config["read_header_timeout"]),
283
+ "read-timeout": seconds_duration(run_config["read_timeout"]),
284
+ "write-timeout": seconds_duration(run_config["write_timeout"]),
285
+ "idle-timeout": seconds_duration(run_config["idle_timeout"]),
286
+ "shutdown-timeout": seconds_duration(run_config["shutdown_timeout"])
287
+ }.compact
288
+ end
289
+
290
+ # A passthrough flag that a named key already emits would be optionized
291
+ # twice, and kamal-proxy would silently take the last one. Refuse instead.
292
+ # Only Proxy::Run knows which flags the named keys produce, so the check
293
+ # cannot live in the validator with the rest.
294
+ def ensure_no_conflicting_flags
295
+ conflicts = flags.keys.map(&:to_s) & named_run_command_options.keys.map(&:to_s)
296
+ return true if conflicts.empty?
297
+
298
+ raise Kamal::ConfigurationError,
299
+ "#{@context}/flags: #{conflicts.sort.join(", ")} is already set by a named key - remove one of them"
300
+ end
301
+
302
+ # Where cached responses are kept, which is a property of the proxy rather
303
+ # than of any one service - the policy that fills the cache is per service,
304
+ # in proxy/cache. The store itself is deliberately absent: a store URL may
305
+ # embed credentials, so it travels as CACHE_STORE in the secrets env file
306
+ # (kamal-proxy reads it as the --cache-store default) rather than on a
307
+ # command line that lands in process listings and the audit log.
308
+ def cache_options
309
+ cache = run_config["cache"] || {}
310
+
311
+ {
312
+ "cache-store-timeout": seconds_duration(cache["store_timeout"]),
313
+ "cache-memory-size": cache["memory_size"]
314
+ }.compact
315
+ end
316
+
317
+ def cache_store
318
+ run_config.dig("cache", "store")
319
+ end
320
+
321
+ def cache_store_env
322
+ cache_store.present? ? { "CACHE_STORE" => cache_store } : {}
323
+ end
324
+
325
+ def secret_names
326
+ [ *acme.credential_names, ("CACHE_STORE" if cache_store.present?) ].compact
327
+ end
328
+
329
+ def secrets_args
330
+ argumentize "--env-file", secrets_path if secrets?
331
+ end
332
+
144
333
  def format_bind_ip(ip)
145
334
  # Ensure IPv6 address inside square brackets - e.g. [::1]
146
335
  if ip =~ Resolv::IPv6::Regex && ip !~ /\A\[.*\]\z/