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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Remote readiness checks for a single host, run inside an SSHKit backend.
|
|
2
|
+
#
|
|
3
|
+
# Every check rescues StandardError at its boundary: the doctor's contract is
|
|
4
|
+
# to report broken environments, not crash on them, and a failed connection can
|
|
5
|
+
# raise anything from Net::SSH errors to Errno and DNS resolution errors.
|
|
6
|
+
class Kamal::Cli::Doctor::HostChecks
|
|
7
|
+
attr_reader :host, :sshkit, :proxy_host
|
|
8
|
+
delegate :execute, :capture_with_info, to: :sshkit
|
|
9
|
+
|
|
10
|
+
def initialize(host, sshkit, proxy_host:)
|
|
11
|
+
@host = host
|
|
12
|
+
@sshkit = sshkit
|
|
13
|
+
@proxy_host = proxy_host
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
checks = { ssh: ssh_check }
|
|
18
|
+
|
|
19
|
+
if checks[:ssh].ok?
|
|
20
|
+
checks[:docker] = docker_check
|
|
21
|
+
checks[:registry] = registry_check
|
|
22
|
+
checks.merge!(proxy_checks) if proxy_host
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
checks
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def result(check, status, detail)
|
|
30
|
+
Kamal::Cli::Doctor::Result.new(check, host, status, detail)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ssh_check
|
|
34
|
+
execute "true"
|
|
35
|
+
result :ssh, :ok, "connected"
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
result :ssh, :fail, "#{e.class}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def docker_check
|
|
41
|
+
if execute(*KAMAL.docker.running?, raise_on_non_zero_exit: false)
|
|
42
|
+
result :docker, :ok, "docker is installed and running"
|
|
43
|
+
else
|
|
44
|
+
result :docker, :fail, "docker is not installed or not running (run `kamal server bootstrap`)"
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
result :docker, :fail, "#{e.class}: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def registry_check
|
|
51
|
+
return result(:registry, :ok, "local registry, no login required") if KAMAL.registry.local?
|
|
52
|
+
|
|
53
|
+
if execute(*KAMAL.registry.login, raise_on_non_zero_exit: false)
|
|
54
|
+
result :registry, :ok, "logged in to #{registry_name}"
|
|
55
|
+
else
|
|
56
|
+
result :registry, :fail, "docker login to #{registry_name} failed (check registry credentials)"
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
result :registry, :fail, "#{e.class}: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def proxy_checks
|
|
63
|
+
version, version_error = capture_proxy_version
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
proxy_image: proxy_image_check,
|
|
67
|
+
proxy_version: proxy_version_check(version, version_error),
|
|
68
|
+
proxy_socket: proxy_socket_check(proxy_running: version.present?),
|
|
69
|
+
ports: ports_check(proxy_running: version.present?)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def capture_proxy_version
|
|
74
|
+
[ capture_with_info(*KAMAL.proxy(host).version).strip.presence, nil ]
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
[ nil, e ]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def proxy_image_check
|
|
80
|
+
image = expected_proxy_image
|
|
81
|
+
|
|
82
|
+
if execute(*KAMAL.docker.manifest_available?(image), raise_on_non_zero_exit: false)
|
|
83
|
+
result :proxy_image, :ok, "#{image} manifest is fetchable"
|
|
84
|
+
else
|
|
85
|
+
result :proxy_image, :fail, "cannot fetch the manifest for #{image} (registry unreachable or unauthorized)"
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
result :proxy_image, :fail, "#{e.class}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def proxy_version_check(version, error)
|
|
92
|
+
minimum = Kamal::Configuration::Proxy::Run::MINIMUM_VERSION
|
|
93
|
+
|
|
94
|
+
if error
|
|
95
|
+
result :proxy_version, :warn, "could not determine the running version (#{error.message})"
|
|
96
|
+
elsif version.nil?
|
|
97
|
+
result :proxy_version, :ok, "not running (will be started on deploy)"
|
|
98
|
+
elsif Kamal::Utils.older_version?(version, minimum)
|
|
99
|
+
result :proxy_version, :fail, "#{version} is older than the minimum #{minimum}, run `kamal proxy reboot` to update"
|
|
100
|
+
else
|
|
101
|
+
result :proxy_version, :ok, "#{version} (minimum #{minimum})"
|
|
102
|
+
end
|
|
103
|
+
rescue ArgumentError
|
|
104
|
+
result :proxy_version, :warn, "running image tag #{version} is not a version number"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What a container path has to look like to count as a container runtime
|
|
108
|
+
# socket when the config no longer names one.
|
|
109
|
+
DOCKER_SOCKET_PATTERN = %r{docker\.sock\z}
|
|
110
|
+
|
|
111
|
+
# The config-time sleep/docker_socket pairing check covers the *current*
|
|
112
|
+
# config; the running container keeps whatever it was booted with. A proxy
|
|
113
|
+
# from before the socket was added silently lacks the mount - the failure
|
|
114
|
+
# mode is one hung request when a sleeping service never wakes - and one
|
|
115
|
+
# from before it was removed keeps root-equivalent host access.
|
|
116
|
+
def proxy_socket_check(proxy_running:)
|
|
117
|
+
expected = KAMAL.config.proxy_run(host)&.docker_socket
|
|
118
|
+
|
|
119
|
+
unless proxy_running
|
|
120
|
+
detail = expected ? "docker socket #{expected} will be mounted on boot" : "no docker socket configured"
|
|
121
|
+
return result(:proxy_socket, :ok, detail)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
mounted = capture_with_info(*KAMAL.proxy(host).mount_destinations, raise_on_non_zero_exit: false).split("\n").map(&:strip)
|
|
125
|
+
|
|
126
|
+
if expected
|
|
127
|
+
if mounted.include?(expected)
|
|
128
|
+
result :proxy_socket, :ok, "docker socket #{expected} is mounted"
|
|
129
|
+
elsif sleep_configured?
|
|
130
|
+
result :proxy_socket, :fail, "the running kamal-proxy has no #{expected} mount, so sleeping services never wake - run `kamal proxy reboot`"
|
|
131
|
+
else
|
|
132
|
+
# Nothing sleeps yet, so nothing hangs - drift rather than breakage.
|
|
133
|
+
result :proxy_socket, :warn, "the running kamal-proxy has no #{expected} mount - run `kamal proxy reboot` to apply the current configuration"
|
|
134
|
+
end
|
|
135
|
+
elsif (stray = mounted.grep(DOCKER_SOCKET_PATTERN).first)
|
|
136
|
+
result :proxy_socket, :warn, "the running kamal-proxy mounts #{stray} but the config no longer asks for it - " \
|
|
137
|
+
"the socket is root-equivalent host access; `kamal proxy reboot` removes it"
|
|
138
|
+
else
|
|
139
|
+
result :proxy_socket, :ok, "no docker socket configured or mounted"
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
result :proxy_socket, :warn, "could not check the docker socket (#{e.message})"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sleep_configured?
|
|
146
|
+
KAMAL.config.roles.any? { |role| role.running_proxy? && role.proxy.proxy_config["sleep"].present? }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ports_check(proxy_running:)
|
|
150
|
+
run_config = KAMAL.config.proxy_run(host)
|
|
151
|
+
return result(:ports, :ok, "proxy ports are not published, nothing to check") if run_config && !run_config.publish?
|
|
152
|
+
|
|
153
|
+
http_port = run_config&.http_port || Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT
|
|
154
|
+
https_port = run_config&.https_port || Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT
|
|
155
|
+
|
|
156
|
+
if proxy_running
|
|
157
|
+
result :ports, :ok, "ports #{http_port}/#{https_port} held by the running kamal-proxy"
|
|
158
|
+
elsif (busy = busy_ports(http_port, https_port)).any?
|
|
159
|
+
result :ports, :fail, "port(s) #{busy.join(", ")} already in use by another process"
|
|
160
|
+
else
|
|
161
|
+
result :ports, :ok, "ports #{http_port}/#{https_port} free"
|
|
162
|
+
end
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
result :ports, :warn, "could not check ports (#{e.message})"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def busy_ports(*ports)
|
|
168
|
+
ports.select { |port| capture_with_info(*KAMAL.server.listeners_on(port), raise_on_non_zero_exit: false).present? }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def expected_proxy_image
|
|
172
|
+
KAMAL.config.proxy_run(host)&.image || "#{KAMAL.config.proxy_boot.image_default}:#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def registry_name
|
|
176
|
+
KAMAL.config.registry.server || "Docker Hub"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "kamal/sshkit_with_ext"
|
|
2
|
+
|
|
3
|
+
# Runs read-only deploy readiness checks and collects the results, without ever
|
|
4
|
+
# raising on a broken environment - failures become failing results instead.
|
|
5
|
+
class Kamal::Cli::Doctor
|
|
6
|
+
include SSHKit::DSL
|
|
7
|
+
|
|
8
|
+
HOST_CHECKS = %i[ ssh docker registry proxy_image proxy_version proxy_socket ports ]
|
|
9
|
+
|
|
10
|
+
CHECK_TITLES = {
|
|
11
|
+
ssh: "SSH",
|
|
12
|
+
docker: "Docker",
|
|
13
|
+
registry: "Registry",
|
|
14
|
+
proxy_image: "Proxy image",
|
|
15
|
+
proxy_version: "Proxy version",
|
|
16
|
+
proxy_socket: "Proxy docker socket",
|
|
17
|
+
ports: "Ports",
|
|
18
|
+
dns: "DNS",
|
|
19
|
+
certificate: "Certificates",
|
|
20
|
+
readiness: "Readiness"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
STATUS_COLORS = { ok: :green, warn: :yellow, fail: :red }.freeze
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:check, :target, :status, :detail) do
|
|
26
|
+
def ok?
|
|
27
|
+
status == :ok
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def warn?
|
|
31
|
+
status == :warn
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fail?
|
|
35
|
+
status == :fail
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def title
|
|
39
|
+
CHECK_TITLES[check]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
"#{status.to_s.upcase} #{target}: #{detail}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :results
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@results = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run
|
|
54
|
+
@results = host_check_results + endpoint_check_results + config_check_results
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failures
|
|
58
|
+
results.select(&:fail?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def warnings
|
|
62
|
+
results.select(&:warn?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def successful?
|
|
66
|
+
failures.none?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
def host_check_results
|
|
71
|
+
results_by_host = collect_host_checks
|
|
72
|
+
|
|
73
|
+
HOST_CHECKS.flat_map do |check|
|
|
74
|
+
KAMAL.hosts.filter_map { |host| results_by_host.dig(host, check) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect_host_checks
|
|
79
|
+
results_by_host = {}
|
|
80
|
+
mutex = Mutex.new
|
|
81
|
+
proxy_hosts = KAMAL.proxy_hosts
|
|
82
|
+
error = nil
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
on(KAMAL.hosts) do |host|
|
|
86
|
+
checks = Kamal::Cli::Doctor::HostChecks.new(host.hostname, self, proxy_host: proxy_hosts.include?(host.hostname)).run
|
|
87
|
+
mutex.synchronize { results_by_host[host.hostname] = checks }
|
|
88
|
+
end
|
|
89
|
+
# Only ExecuteError: sshkit 1.25 has no MultipleExecuteError, and naming
|
|
90
|
+
# a constant that does not exist turns "a host's checks failed" into a
|
|
91
|
+
# NameError - the opposite of the doctor's never-crash contract.
|
|
92
|
+
rescue SSHKit::Runner::ExecuteError => e
|
|
93
|
+
# Per-check errors are captured inside HostChecks; getting here means a
|
|
94
|
+
# host's checks never completed at all. Record those as SSH failures below.
|
|
95
|
+
error = e
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
KAMAL.hosts.each do |host|
|
|
99
|
+
results_by_host[host] ||= { ssh: Result.new(:ssh, host, :fail, "could not run checks (#{error&.message || "connection failed"})") }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
results_by_host
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def endpoint_check_results
|
|
106
|
+
Kamal::Cli::Doctor::EndpointChecks.new.run
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def config_check_results
|
|
110
|
+
Kamal::Cli::Doctor::ConfigChecks.new.run
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Raised when the config declares a healthcheck the running container does not have.
|
|
2
|
+
#
|
|
3
|
+
# Deliberately NOT a Kamal::Cli::Healthcheck::Error: the poller retries that class until
|
|
4
|
+
# deploy_timeout, and drift is deterministic — `.State.Health` exists from the moment a
|
|
5
|
+
# container with a healthcheck is created, so retrying only burns the timeout before
|
|
6
|
+
# reporting the same thing.
|
|
7
|
+
class Kamal::Cli::Healthcheck::DriftError < StandardError; end
|
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
module Kamal::Cli::Healthcheck::Poller
|
|
2
2
|
extend self
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
NO_HEALTHCHECK = Kamal::Commands::Base::NO_HEALTHCHECK
|
|
5
|
+
|
|
6
|
+
def wait_for_healthy(role:, &block)
|
|
5
7
|
attempt = 1
|
|
6
8
|
timeout_at = Time.now + KAMAL.config.deploy_timeout
|
|
7
|
-
readiness_delay =
|
|
9
|
+
readiness_delay = role.readiness_delay
|
|
8
10
|
|
|
9
11
|
begin
|
|
10
12
|
status = block.call
|
|
11
13
|
|
|
12
|
-
if status
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
if unchecked?(status)
|
|
15
|
+
ensure_no_healthcheck_drift(role, status)
|
|
16
|
+
|
|
17
|
+
if docker_state(status) == "running"
|
|
18
|
+
announce_missing_gate(role, readiness_delay)
|
|
19
|
+
|
|
20
|
+
# Wait for the readiness delay and confirm it is still running
|
|
21
|
+
if readiness_delay > 0
|
|
22
|
+
sleep readiness_delay
|
|
23
|
+
status = block.call
|
|
24
|
+
ensure_no_healthcheck_drift(role, status)
|
|
25
|
+
end
|
|
18
26
|
end
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
unless
|
|
29
|
+
unless acceptable?(status)
|
|
22
30
|
raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})"
|
|
23
31
|
end
|
|
24
32
|
rescue Kamal::Cli::Healthcheck::Error => e
|
|
25
33
|
time_left = timeout_at - Time.now
|
|
26
34
|
if time_left > 0
|
|
27
|
-
|
|
35
|
+
sleep_for = [ attempt, time_left ].min
|
|
36
|
+
elapsed = KAMAL.config.deploy_timeout - time_left
|
|
37
|
+
info "Container not ready yet, retrying in #{sleep_for.ceil}s (#{elapsed.round}s elapsed, #{time_left.round}s left)"
|
|
38
|
+
sleep sleep_for
|
|
28
39
|
attempt += 1
|
|
29
40
|
retry
|
|
30
41
|
else
|
|
@@ -36,7 +47,46 @@ module Kamal::Cli::Healthcheck::Poller
|
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
private
|
|
50
|
+
def unchecked?(status)
|
|
51
|
+
status.to_s.start_with?("#{NO_HEALTHCHECK}:")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def docker_state(status)
|
|
55
|
+
status.to_s.delete_prefix("#{NO_HEALTHCHECK}:")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def acceptable?(status)
|
|
59
|
+
status == "healthy" || (unchecked?(status) && docker_state(status) == "running")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The config asked docker to probe this container and docker is not probing it — the flags
|
|
63
|
+
# never reached `docker run`. Accepting it would let the deploy pass without ever checking
|
|
64
|
+
# readiness, which is exactly the failure the healthcheck was added to prevent.
|
|
65
|
+
def ensure_no_healthcheck_drift(role, status)
|
|
66
|
+
return unless %i[ healthcheck docker_options ].include?(role.readiness_source)
|
|
67
|
+
|
|
68
|
+
raise Kamal::Cli::Healthcheck::DriftError,
|
|
69
|
+
"#{role.name} declares a healthcheck but the container reports none (#{status}) — the flags never reached docker. " \
|
|
70
|
+
"Compare `servers/#{role.name}` in your deploy config against `docker inspect --format '{{json .Config.Healthcheck}}'` " \
|
|
71
|
+
"on the container; the deploy would otherwise be accepted without ever probing it."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A role nobody made a readiness decision for still boots — the config-time warning owns that
|
|
75
|
+
# policy — but it must not do so silently, because the delay is the only gate there is.
|
|
76
|
+
def announce_missing_gate(role, readiness_delay)
|
|
77
|
+
if role.readiness_gated?
|
|
78
|
+
info "Container is running, waiting for readiness delay of #{readiness_delay} seconds"
|
|
79
|
+
else
|
|
80
|
+
warn "#{role.name} has no healthcheck — accepting the container as ready after the #{readiness_delay}s readiness delay alone. " \
|
|
81
|
+
"Add a `healthcheck:` block to gate the deploy on readiness, or `healthcheck: false` to accept this explicitly."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
39
85
|
def info(message)
|
|
40
86
|
SSHKit.config.output.info(message)
|
|
41
87
|
end
|
|
88
|
+
|
|
89
|
+
def warn(message)
|
|
90
|
+
SSHKit.config.output.warn(message)
|
|
91
|
+
end
|
|
42
92
|
end
|
data/lib/kamal/cli/main.rb
CHANGED
|
@@ -23,6 +23,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
|
|
23
23
|
runtime = print_runtime do
|
|
24
24
|
invoke_options = deploy_options
|
|
25
25
|
|
|
26
|
+
print_config_banner
|
|
27
|
+
|
|
28
|
+
say "Validate configuration and secrets...", :magenta
|
|
29
|
+
KAMAL.config.validate_secrets!(include_accessories: boot_accessories)
|
|
30
|
+
|
|
26
31
|
if options[:skip_push]
|
|
27
32
|
say "Pull app image...", :magenta
|
|
28
33
|
invoke "kamal:cli:build:pull", [], invoke_options
|
|
@@ -66,6 +71,11 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
|
|
66
71
|
runtime = print_runtime do
|
|
67
72
|
invoke_options = deploy_options
|
|
68
73
|
|
|
74
|
+
print_config_banner
|
|
75
|
+
|
|
76
|
+
say "Validate configuration and secrets...", :magenta
|
|
77
|
+
KAMAL.config.validate_secrets!
|
|
78
|
+
|
|
69
79
|
if options[:skip_push]
|
|
70
80
|
say "Pull app image...", :magenta
|
|
71
81
|
invoke "kamal:cli:build:pull", [], invoke_options
|
|
@@ -153,6 +163,23 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
|
|
153
163
|
puts "No documentation found for #{section}"
|
|
154
164
|
end
|
|
155
165
|
|
|
166
|
+
desc "doctor", "Diagnose deploy readiness of servers, registry, proxy, ports, DNS, certificates, and per-role readiness gates"
|
|
167
|
+
def doctor
|
|
168
|
+
say "Running readiness checks...", :magenta
|
|
169
|
+
pre_connect_if_required
|
|
170
|
+
|
|
171
|
+
doctor = Kamal::Cli::Doctor.new
|
|
172
|
+
doctor.run
|
|
173
|
+
|
|
174
|
+
print_doctor_report doctor.results
|
|
175
|
+
|
|
176
|
+
if doctor.successful?
|
|
177
|
+
say doctor_summary(doctor), :green
|
|
178
|
+
else
|
|
179
|
+
raise Kamal::Cli::DoctorError, doctor_failure_message(doctor)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
156
183
|
desc "init", "Create config stub in config/deploy.yml and secrets stub in .kamal"
|
|
157
184
|
option :bundle, type: :boolean, default: false, desc: "Add Kamal to the Gemfile and create a bin/kamal binstub"
|
|
158
185
|
def init
|
|
@@ -296,4 +323,40 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
|
|
296
323
|
base_options = base_options.except("no_cache") unless base_options["no_cache"]
|
|
297
324
|
{ "version" => KAMAL.config.version }.merge(base_options)
|
|
298
325
|
end
|
|
326
|
+
|
|
327
|
+
def print_doctor_report(results)
|
|
328
|
+
results.group_by(&:title).each do |title, rows|
|
|
329
|
+
say title
|
|
330
|
+
rows.each { |row| say " #{row}", Kamal::Cli::Doctor::STATUS_COLORS[row.status] }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def doctor_summary(doctor)
|
|
335
|
+
if doctor.warnings.any?
|
|
336
|
+
"Looks ready to deploy, with #{doctor.warnings.count} warning(s) to review"
|
|
337
|
+
else
|
|
338
|
+
"Everything looks ready to deploy"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def doctor_failure_message(doctor)
|
|
343
|
+
failing = doctor.failures.map { |failure| " #{failure.title} - #{failure}" }.join("\n")
|
|
344
|
+
"Found #{doctor.failures.count} failing check(s):\n#{failing}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def print_config_banner
|
|
348
|
+
config = KAMAL.config
|
|
349
|
+
|
|
350
|
+
say "Deploying #{config.service}#{" to #{config.destination}" if config.destination} (version #{config.abbreviated_version})", :magenta
|
|
351
|
+
config.roles.each do |role|
|
|
352
|
+
hosts = "#{role.hosts.count} #{"host".pluralize(role.hosts.count)} (#{role.hosts.join(", ")})"
|
|
353
|
+
say " #{role.name}: #{hosts} — readiness: #{role.readiness_description}", (:yellow if role.readiness_source == :none)
|
|
354
|
+
end
|
|
355
|
+
say " proxy: #{config.proxy_hosts.join(", ")}" if config.proxy_hosts.any?
|
|
356
|
+
if config.proxy.load_balancing?
|
|
357
|
+
reason = " (auto-enabled: primary role #{config.primary_role.name} has #{config.primary_role.hosts.count} hosts)" unless config.proxy.loadbalancer.present?
|
|
358
|
+
say " loadbalancer: #{config.proxy.effective_loadbalancer}#{reason}"
|
|
359
|
+
end
|
|
360
|
+
say " timeouts: deploy #{config.deploy_timeout}s, drain #{config.drain_timeout}s, readiness delay #{config.readiness_delay}s"
|
|
361
|
+
end
|
|
299
362
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class Kamal::Cli::Proxy::Drift
|
|
2
|
+
attr_reader :host, :sshkit
|
|
3
|
+
delegate :capture_with_info, to: :sshkit
|
|
4
|
+
|
|
5
|
+
def initialize(host, sshkit)
|
|
6
|
+
@host = host
|
|
7
|
+
@sshkit = sshkit
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def container_exists?
|
|
11
|
+
capture_with_info(*proxy.container_id, raise_on_non_zero_exit: false).strip.present?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A proxy container has drifted when it was started with a different config
|
|
15
|
+
# digest than the one the current configuration produces. Containers booted
|
|
16
|
+
# by older kamal versions carry no digest label and count as drifted, so
|
|
17
|
+
# they converge on the first deploy after upgrading.
|
|
18
|
+
def drifted?
|
|
19
|
+
return @drifted if defined?(@drifted)
|
|
20
|
+
@drifted = container_exists? && current_digest != expected_digest
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def expected_digest
|
|
24
|
+
@expected_digest ||= if proxy.proxy_run_config
|
|
25
|
+
proxy.proxy_run_config.config_digest
|
|
26
|
+
else
|
|
27
|
+
Kamal::Configuration::Proxy::Run.digest(capture_with_info(*proxy.boot_config).strip)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def current_digest
|
|
33
|
+
capture_with_info(*proxy.config_digest, raise_on_non_zero_exit: false).strip
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def proxy
|
|
37
|
+
@proxy ||= KAMAL.proxy(host)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Ownership bookkeeping for a load balancer host that more than one kamal app
|
|
2
|
+
# may be deploying to. Kamal only ever sees one deploy.yml at a time, so the
|
|
3
|
+
# other apps are visible solely through the files they left on the host.
|
|
4
|
+
class Kamal::Cli::Proxy::LoadbalancerClaim
|
|
5
|
+
attr_reader :host, :sshkit
|
|
6
|
+
delegate :capture_with_info, :execute, :upload!, :warn, to: :sshkit
|
|
7
|
+
|
|
8
|
+
def initialize(host, sshkit)
|
|
9
|
+
@host = host
|
|
10
|
+
@sshkit = sshkit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The load balancer registers services under the bare service name, so a
|
|
14
|
+
# second app - or a second destination of this app - deploying the same name
|
|
15
|
+
# would silently take over its routes. Claim the name on first deploy and fail
|
|
16
|
+
# loudly rather than quietly stealing it.
|
|
17
|
+
def claim_service
|
|
18
|
+
owner = capture_with_info(*commands.read_service_owner, raise_on_non_zero_exit: false).strip
|
|
19
|
+
|
|
20
|
+
if owner.present? && owner != config.owner_token
|
|
21
|
+
raise "Service '#{config.config.service}' is already registered on the load balancer at #{host} by #{repository_in(owner)}. " \
|
|
22
|
+
"Deploying would take over its routes - rename this app's service or use a different load balancer."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
execute *commands.ensure_services_directory
|
|
26
|
+
upload! StringIO.new(config.owner_token), config.service_owner_file
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Every app sharing a load balancer boots the same container, so their
|
|
30
|
+
# proxy/run configurations have to agree. A mismatch owned by another app is
|
|
31
|
+
# an error; a mismatch owned by this app is ordinary drift, reported the same
|
|
32
|
+
# way the per-host proxy reports it and fixed by `kamal proxy reboot`.
|
|
33
|
+
def claim_run_config(replace: false)
|
|
34
|
+
record = capture_with_info(*commands.read_run_config_record, raise_on_non_zero_exit: false).strip
|
|
35
|
+
|
|
36
|
+
if record.present?
|
|
37
|
+
*owner_parts, digest = record.split(" ")
|
|
38
|
+
owner = owner_parts.join(" ")
|
|
39
|
+
|
|
40
|
+
if digest != config.run_config_digest
|
|
41
|
+
if owner != config.owner_token
|
|
42
|
+
raise "The load balancer on #{host} was booted by #{repository_in(owner)} with a different proxy/run configuration. " \
|
|
43
|
+
"Apps sharing a load balancer must agree on proxy/run - align the configurations or use a different load balancer host."
|
|
44
|
+
elsif !replace
|
|
45
|
+
warn "The load balancer on #{host} is running with a configuration that no longer matches the deploy config - " \
|
|
46
|
+
"run `kamal proxy reboot` to apply the new configuration."
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
execute *commands.ensure_directory
|
|
53
|
+
upload! StringIO.new(config.run_config_record), config.run_config_file
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
def config
|
|
58
|
+
KAMAL.loadbalancer_config
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def commands
|
|
62
|
+
KAMAL.loadbalancer
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Owner tokens are "<service-and-destination> <repository>"; the repository
|
|
66
|
+
# is the half an operator recognises as "the other app".
|
|
67
|
+
def repository_in(owner)
|
|
68
|
+
owner.split(" ").last
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Replaces the loadbalancer container with one booted from the current
|
|
2
|
+
# configuration. Shared by `kamal proxy reboot` and the drift-detected reboot
|
|
3
|
+
# on boot - the same sequence either way, so the two paths cannot diverge.
|
|
4
|
+
#
|
|
5
|
+
# Unlike the proxy hosts' port-holder handoff this is stop -> run: the
|
|
6
|
+
# loadbalancer keeps its service state in the config volume, which the
|
|
7
|
+
# replacement container re-mounts, so every app's routes survive the gap.
|
|
8
|
+
class Kamal::Cli::Proxy::LoadbalancerReboot
|
|
9
|
+
attr_reader :host, :sshkit
|
|
10
|
+
delegate :execute, :capture_with_info, :info, :upload!, to: :sshkit
|
|
11
|
+
|
|
12
|
+
def initialize(host, sshkit)
|
|
13
|
+
@host = host
|
|
14
|
+
@sshkit = sshkit
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
execute *KAMAL.auditor.record("Rebooted loadbalancer"), verbosity: :debug
|
|
19
|
+
execute *KAMAL.registry.login
|
|
20
|
+
|
|
21
|
+
info "Stopping and removing #{KAMAL.loadbalancer.container_name} on #{host}, if running..."
|
|
22
|
+
execute *KAMAL.loadbalancer.stop, raise_on_non_zero_exit: false
|
|
23
|
+
execute *KAMAL.loadbalancer.remove_container
|
|
24
|
+
|
|
25
|
+
if (lb_run = KAMAL.loadbalancer_config.run).secrets?
|
|
26
|
+
execute *KAMAL.loadbalancer.ensure_proxy_directory
|
|
27
|
+
upload! lb_run.secrets_io, lb_run.secrets_path, mode: "0600"
|
|
28
|
+
else
|
|
29
|
+
execute *KAMAL.loadbalancer.remove_proxy_secrets_file, raise_on_non_zero_exit: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
execute *KAMAL.loadbalancer.ensure_apps_config_directory
|
|
33
|
+
Kamal::Cli::Proxy::LoadbalancerClaim.new(host, sshkit).claim_run_config(replace: true)
|
|
34
|
+
execute *KAMAL.loadbalancer.run
|
|
35
|
+
|
|
36
|
+
# kamal-proxy keeps its service state in the config volume, which the
|
|
37
|
+
# replacement container re-mounts - every app's routes survive.
|
|
38
|
+
services = capture_with_info(*KAMAL.loadbalancer.list).strip
|
|
39
|
+
info "Services registered on the load balancer at #{host} after reboot:\n#{services}"
|
|
40
|
+
end
|
|
41
|
+
end
|