dash 2.12.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/kamal/cli/app/boot.rb +47 -11
- data/lib/kamal/cli/app/rollout_boot.rb +59 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +12 -3
- data/lib/kamal/cli/app.rb +74 -7
- data/lib/kamal/cli/base.rb +41 -32
- data/lib/kamal/cli/doctor/config_checks.rb +28 -0
- data/lib/kamal/cli/doctor/endpoint_checks.rb +114 -0
- data/lib/kamal/cli/doctor/host_checks.rb +178 -0
- data/lib/kamal/cli/doctor.rb +112 -0
- data/lib/kamal/cli/healthcheck/drift_error.rb +7 -0
- data/lib/kamal/cli/healthcheck/poller.rb +60 -10
- data/lib/kamal/cli/main.rb +63 -0
- data/lib/kamal/cli/proxy/drift.rb +39 -0
- data/lib/kamal/cli/proxy/loadbalancer_claim.rb +70 -0
- data/lib/kamal/cli/proxy/loadbalancer_reboot.rb +41 -0
- data/lib/kamal/cli/proxy/reboot.rb +172 -0
- data/lib/kamal/cli/proxy.rb +200 -26
- data/lib/kamal/cli/prune.rb +7 -4
- data/lib/kamal/cli/templates/sample_hooks/post-app-stop.sample +9 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-deploy.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-app-stop.sample +12 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-proxy-deploy.sample +3 -0
- data/lib/kamal/cli.rb +1 -0
- data/lib/kamal/commands/app/proxy.rb +12 -0
- data/lib/kamal/commands/app.rb +8 -0
- data/lib/kamal/commands/base.rb +11 -1
- data/lib/kamal/commands/docker.rb +5 -0
- data/lib/kamal/commands/loadbalancer.rb +76 -34
- data/lib/kamal/commands/proxy.rb +105 -11
- data/lib/kamal/commands/prune.rb +16 -2
- data/lib/kamal/commands/server.rb +5 -0
- data/lib/kamal/configuration/accessory.rb +9 -8
- data/lib/kamal/configuration/boot.rb +38 -7
- data/lib/kamal/configuration/docs/accessory.yml +28 -1
- data/lib/kamal/configuration/docs/boot.yml +17 -2
- data/lib/kamal/configuration/docs/configuration.yml +2 -1
- data/lib/kamal/configuration/docs/proxy.yml +844 -24
- data/lib/kamal/configuration/docs/role.yml +86 -0
- data/lib/kamal/configuration/loadbalancer.rb +70 -7
- data/lib/kamal/configuration/proxy/acme.rb +69 -0
- data/lib/kamal/configuration/proxy/run.rb +194 -5
- data/lib/kamal/configuration/proxy.rb +495 -18
- data/lib/kamal/configuration/role/healthcheck.rb +95 -0
- data/lib/kamal/configuration/role.rb +104 -1
- data/lib/kamal/configuration/validator/proxy.rb +623 -10
- data/lib/kamal/configuration/validator/role.rb +26 -0
- data/lib/kamal/configuration/validator.rb +26 -3
- data/lib/kamal/configuration.rb +197 -0
- data/lib/kamal/sshkit_with_ext.rb +27 -2
- data/lib/kamal/utils.rb +22 -2
- data/lib/kamal/version.rb +1 -1
- data/lib/kamal.rb +11 -0
- metadata +18 -2
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 = "
|
|
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
|
-
|
|
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/
|