dash 2.12.0 → 3.0.1
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 +87 -0
- data/lib/kamal/cli/proxy/reboot.rb +173 -0
- data/lib/kamal/cli/proxy.rb +201 -34
- 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 +111 -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 +81 -6
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|