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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6645870f4a0452741a5974d1d822e73fae0d4a04aec353196af3c02b3fb149d2
|
|
4
|
+
data.tar.gz: 23d1738ee101bf3eda8c03c363845dacc396403767271f7fbda7cd6656b9f0ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 972ac9e339a4b56463c2f6caaf764a272cc01002101345e2696474fb01817d3a36dcb2c825b6f188521da82c764764323ff1b92b34ced280a330eb5a1d1bde0d
|
|
7
|
+
data.tar.gz: 7e698f1dbb093d79787f7c48aec3053742c90bfe08261b942d66a208b8051f53b8154969f796c82e29dac893b531dd9bd945a11015f598f4ac76577b271ce1fe
|
data/lib/kamal/cli/app/boot.rb
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
class Kamal::Cli::App::Boot
|
|
2
|
-
attr_reader :host, :role, :version, :barrier, :sshkit
|
|
2
|
+
attr_reader :host, :role, :version, :barrier, :sshkit, :cli
|
|
3
3
|
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
|
|
4
|
+
delegate :run_hook, to: :cli
|
|
4
5
|
delegate :assets?, :running_proxy?, to: :role
|
|
5
6
|
|
|
6
|
-
def initialize(host, role, sshkit, version, barrier)
|
|
7
|
+
def initialize(host, role, sshkit, version, barrier, cli)
|
|
7
8
|
@host = host
|
|
8
9
|
@role = role
|
|
9
10
|
@version = version
|
|
10
11
|
@barrier = barrier
|
|
11
12
|
@sshkit = sshkit
|
|
13
|
+
@cli = cli
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def run
|
|
@@ -54,25 +56,65 @@ class Kamal::Cli::App::Boot
|
|
|
54
56
|
if running_proxy?
|
|
55
57
|
endpoint = capture_with_info(*app.container_id_for_version(version)).strip
|
|
56
58
|
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
59
|
+
|
|
60
|
+
run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
61
|
+
info "Deploying #{role} on #{host} via kamal-proxy (waiting up to #{KAMAL.config.deploy_timeout}s for it to become healthy)..."
|
|
57
62
|
execute *app.deploy(target: endpoint)
|
|
63
|
+
run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
58
64
|
else
|
|
59
|
-
Kamal::Cli::Healthcheck::Poller.wait_for_healthy
|
|
65
|
+
Kamal::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { health_status }
|
|
60
66
|
end
|
|
61
67
|
rescue => e
|
|
62
68
|
error "Failed to boot #{role} on #{host}"
|
|
69
|
+
dump_diagnostics
|
|
63
70
|
raise e
|
|
64
71
|
end
|
|
65
72
|
|
|
73
|
+
# An exec probe is docker-invisible — the container declares no healthcheck, so
|
|
74
|
+
# `docker inspect` would only ever report its state. Poll the probe instead.
|
|
75
|
+
def health_status
|
|
76
|
+
role.healthcheck&.exec? ? exec_probe_status : capture_with_info(*app.status(version: version))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def exec_probe_status
|
|
80
|
+
execute *app.health_probe(version: version)
|
|
81
|
+
"healthy"
|
|
82
|
+
rescue SSHKit::Command::Failed
|
|
83
|
+
"exec probe exited non-zero"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every failed boot gets the container log, and the health probe history when the
|
|
87
|
+
# container declares a healthcheck — non-primary roles have no kamal-proxy report to fall back on.
|
|
88
|
+
def dump_diagnostics
|
|
89
|
+
error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
|
|
90
|
+
|
|
91
|
+
health_log = capture_with_info(*app.container_health_log(version: version)).strip
|
|
92
|
+
error health_log unless health_log.empty? || health_log == "null"
|
|
93
|
+
rescue SSHKit::Command::Failed
|
|
94
|
+
error "Could not fetch logs for #{version}"
|
|
95
|
+
end
|
|
96
|
+
|
|
66
97
|
def stop_new_version
|
|
67
98
|
execute *app.stop(version: version), raise_on_non_zero_exit: false
|
|
68
99
|
end
|
|
69
100
|
|
|
70
|
-
def stop_old_version(
|
|
71
|
-
|
|
101
|
+
def stop_old_version(old_version)
|
|
102
|
+
run_stop_hook "pre-app-stop", old_version
|
|
103
|
+
execute *app.stop(version: old_version), raise_on_non_zero_exit: false
|
|
104
|
+
run_stop_hook "post-app-stop", old_version
|
|
105
|
+
|
|
72
106
|
execute *app.clean_up_assets if assets?
|
|
73
107
|
execute *app.clean_up_error_pages if KAMAL.config.error_pages_path
|
|
74
108
|
end
|
|
75
109
|
|
|
110
|
+
# The new version is already live by the time the old one is stopped, so a failing
|
|
111
|
+
# drain hook must not fail the deploy — warn and stop the old container anyway.
|
|
112
|
+
def run_stop_hook(hook, old_version)
|
|
113
|
+
run_hook hook, hosts: host.to_s, role: role.name, version: old_version
|
|
114
|
+
rescue Kamal::Cli::HookError => e
|
|
115
|
+
error "#{e.message}\nContinuing anyway: #{version} is already live for #{role} on #{host}."
|
|
116
|
+
end
|
|
117
|
+
|
|
76
118
|
def release_barrier
|
|
77
119
|
if barrier.open
|
|
78
120
|
info "First #{KAMAL.primary_role} container is healthy on #{host}, booting any other roles"
|
|
@@ -91,12 +133,6 @@ class Kamal::Cli::App::Boot
|
|
|
91
133
|
def close_barrier
|
|
92
134
|
if barrier.close
|
|
93
135
|
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
|
|
94
|
-
begin
|
|
95
|
-
error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
|
|
96
|
-
error capture_with_info(*app.container_health_log(version: version))
|
|
97
|
-
rescue SSHKit::Command::Failed
|
|
98
|
-
error "Could not fetch logs for #{version}"
|
|
99
|
-
end
|
|
100
136
|
end
|
|
101
137
|
end
|
|
102
138
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
class Kamal::Cli::App::RolloutBoot
|
|
2
|
+
attr_reader :host, :role, :version, :sshkit
|
|
3
|
+
delegate :execute, :capture_with_info, :error, :upload!, to: :sshkit
|
|
4
|
+
|
|
5
|
+
def initialize(host, role, sshkit, version)
|
|
6
|
+
@host = host
|
|
7
|
+
@role = role
|
|
8
|
+
@version = version
|
|
9
|
+
@sshkit = sshkit
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
ensure_not_already_deployed
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
start_rollout_target
|
|
17
|
+
rescue
|
|
18
|
+
stop_rollout_target
|
|
19
|
+
raise
|
|
20
|
+
end
|
|
21
|
+
rescue Kamal::Cli::BootError => e
|
|
22
|
+
error e.message
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
def ensure_not_already_deployed
|
|
28
|
+
if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
|
|
29
|
+
raise Kamal::Cli::BootError, "Version #{version} is already deployed for #{role} on #{host}, roll out a different version"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start_rollout_target
|
|
34
|
+
audit "Booted rollout target version #{version}"
|
|
35
|
+
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
36
|
+
|
|
37
|
+
execute *app.ensure_env_directory
|
|
38
|
+
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
39
|
+
|
|
40
|
+
execute *app.run(hostname: hostname)
|
|
41
|
+
|
|
42
|
+
endpoint = capture_with_info(*app.container_id_for_version(version)).strip
|
|
43
|
+
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
44
|
+
|
|
45
|
+
execute *app.rollout_deploy(target: endpoint)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stop_rollout_target
|
|
49
|
+
execute *app.stop(version: version), raise_on_non_zero_exit: false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def app
|
|
53
|
+
@app ||= KAMAL.app(role: role, host: host)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def audit(message)
|
|
57
|
+
execute *KAMAL.auditor(role: role).record(message), verbosity: :debug
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -9,9 +9,12 @@ class Kamal::Cli::App::SslCertificates
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def run
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
return unless role.running_proxy? && (role.proxy.custom_ssl_certificate? || role.proxy.client_ca?)
|
|
13
|
+
|
|
14
|
+
info "Writing SSL certificates for #{role.name} on #{host}"
|
|
15
|
+
execute *app.create_ssl_directory
|
|
16
|
+
|
|
17
|
+
if role.proxy.custom_ssl_certificate?
|
|
15
18
|
if cert_content = role.proxy.certificate_pem_content
|
|
16
19
|
upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644")
|
|
17
20
|
end
|
|
@@ -19,6 +22,12 @@ class Kamal::Cli::App::SslCertificates
|
|
|
19
22
|
upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644")
|
|
20
23
|
end
|
|
21
24
|
end
|
|
25
|
+
|
|
26
|
+
# The client CA arrives the same way the server certificate does: as
|
|
27
|
+
# secret content named in .kamal/secrets, resolved here at upload time.
|
|
28
|
+
if role.proxy.client_ca?
|
|
29
|
+
upload!(StringIO.new(role.proxy.client_ca_pem_content), role.proxy.host_client_ca, mode: "0644")
|
|
30
|
+
end
|
|
22
31
|
end
|
|
23
32
|
|
|
24
33
|
private
|
data/lib/kamal/cli/app.rb
CHANGED
|
@@ -18,17 +18,24 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
18
18
|
|
|
19
19
|
# Primary hosts and roles are returned first, so they can open the barrier
|
|
20
20
|
barrier = Kamal::Cli::Healthcheck::Barrier.new
|
|
21
|
+
cli = self
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
boot_groups = host_boot_groups
|
|
24
|
+
|
|
25
|
+
boot_groups.each_with_index do |hosts, index|
|
|
23
26
|
host_list = Array(hosts).join(",")
|
|
24
27
|
run_hook "pre-app-boot", hosts: host_list
|
|
25
28
|
|
|
26
|
-
on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.
|
|
27
|
-
Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
|
|
29
|
+
on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
|
|
30
|
+
Kamal::Cli::App::Boot.new(host, role, self, version, barrier, cli).run
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
run_hook "post-app-boot", hosts: host_list
|
|
31
|
-
|
|
34
|
+
|
|
35
|
+
# `wait` paces one group against the next, so the last group has nothing to pace
|
|
36
|
+
# against. Without a `limit` there is only ever one group, and waiting at all
|
|
37
|
+
# would be a fixed delay on every deploy buying no staggering.
|
|
38
|
+
sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait && index < boot_groups.size - 1
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
# Tag once the app booted on all hosts
|
|
@@ -43,7 +50,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
43
50
|
desc "start", "Start existing app container on servers"
|
|
44
51
|
def start
|
|
45
52
|
modify(lock: true) do
|
|
46
|
-
|
|
53
|
+
cli = self
|
|
54
|
+
|
|
55
|
+
on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
|
|
47
56
|
app = KAMAL.app(role: role, host: host)
|
|
48
57
|
execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug
|
|
49
58
|
execute *app.start, raise_on_non_zero_exit: false
|
|
@@ -53,7 +62,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
53
62
|
endpoint = capture_with_info(*app.container_id_for_version(version)).strip
|
|
54
63
|
raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
55
64
|
|
|
65
|
+
cli.run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
56
66
|
execute *app.deploy(target: endpoint)
|
|
67
|
+
cli.run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
57
68
|
end
|
|
58
69
|
end
|
|
59
70
|
end
|
|
@@ -62,7 +73,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
62
73
|
desc "stop", "Stop app container on servers"
|
|
63
74
|
def stop
|
|
64
75
|
modify(lock: true) do
|
|
65
|
-
on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.
|
|
76
|
+
on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.parallel_roles?) do |host, role|
|
|
66
77
|
app = KAMAL.app(role: role, host: host)
|
|
67
78
|
execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug
|
|
68
79
|
|
|
@@ -272,6 +283,18 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
272
283
|
end
|
|
273
284
|
end
|
|
274
285
|
|
|
286
|
+
desc "rollout <deploy|set|stop>", "Manage a canary rollout of a new version through kamal-proxy"
|
|
287
|
+
option :percent, type: :numeric, desc: "Percentage of traffic to send to the rollout target"
|
|
288
|
+
option :list, type: :array, desc: "Send requests whose kamal-rollout cookie matches these values to the rollout target"
|
|
289
|
+
def rollout(action)
|
|
290
|
+
case action
|
|
291
|
+
when "deploy" then start_rollout
|
|
292
|
+
when "set" then set_rollout
|
|
293
|
+
when "stop" then stop_rollout
|
|
294
|
+
else raise ArgumentError, "Unknown rollout action '#{action}', expected deploy, set or stop"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
275
298
|
desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
|
|
276
299
|
def remove_container(version)
|
|
277
300
|
modify(lock: true) do
|
|
@@ -323,6 +346,47 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
323
346
|
end
|
|
324
347
|
|
|
325
348
|
private
|
|
349
|
+
def start_rollout
|
|
350
|
+
modify(lock: true) do
|
|
351
|
+
say "Get most recent version available as an image...", :magenta unless options[:version]
|
|
352
|
+
using_version(version_or_latest) do |version|
|
|
353
|
+
say "Start rollout target with version #{version} alongside the live version...", :magenta
|
|
354
|
+
|
|
355
|
+
on(KAMAL.proxy_hosts) do
|
|
356
|
+
KAMAL.roles_on(host).each do |role|
|
|
357
|
+
Kamal::Cli::App::Assets.new(host, role, self).run if role.running_proxy?
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts, parallel: KAMAL.config.parallel_roles?, rolling: true) do |host, role|
|
|
362
|
+
Kamal::Cli::App::RolloutBoot.new(host, role, self, version).run if role.running_proxy?
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def set_rollout
|
|
369
|
+
percent, list = options[:percent], options[:list]
|
|
370
|
+
|
|
371
|
+
if percent.nil? && list.blank?
|
|
372
|
+
raise ArgumentError, "No traffic split provided. You must specify --percent or --list."
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
modify(lock: true) do
|
|
376
|
+
on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
|
|
377
|
+
execute *KAMAL.app(role: role, host: host).rollout_set(percent: percent, list: list) if role.running_proxy?
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def stop_rollout
|
|
383
|
+
modify(lock: true) do
|
|
384
|
+
on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|
|
|
385
|
+
execute *KAMAL.app(role: role, host: host).rollout_stop if role.running_proxy?
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
326
390
|
def hosts_removing_all_roles
|
|
327
391
|
KAMAL.app_hosts.select { |host| KAMAL.roles_on(host).map(&:name).sort == KAMAL.config.host_roles(host.to_s).map(&:name).sort }
|
|
328
392
|
end
|
|
@@ -363,6 +427,9 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
|
|
363
427
|
end
|
|
364
428
|
|
|
365
429
|
def host_boot_groups
|
|
366
|
-
|
|
430
|
+
hosts = KAMAL.app_hosts
|
|
431
|
+
limit = KAMAL.config.boot.limit_for(hosts)
|
|
432
|
+
|
|
433
|
+
limit ? hosts.each_slice(limit).to_a : [ hosts ]
|
|
367
434
|
end
|
|
368
435
|
end
|
data/lib/kamal/cli/base.rb
CHANGED
|
@@ -11,6 +11,10 @@ module Kamal::Cli
|
|
|
11
11
|
class LockHeldError < StandardError; end
|
|
12
12
|
class LockMissingError < StandardError; end
|
|
13
13
|
|
|
14
|
+
# Hooks set process-global state (the hook env and the SSHKit verbosity), so
|
|
15
|
+
# serialize them: some fire from inside SSHKit's per-host threads.
|
|
16
|
+
HOOK_MUTEX = Mutex.new
|
|
17
|
+
|
|
14
18
|
def self.exit_on_failure?() true end
|
|
15
19
|
def self.dynamic_command_class() Kamal::Cli::Alias::Command end
|
|
16
20
|
|
|
@@ -44,6 +48,43 @@ module Kamal::Cli
|
|
|
44
48
|
initialize_commander unless KAMAL.configured?
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
# Public so collaborators like Kamal::Cli::App::Boot can fire hooks, but not a Thor command.
|
|
52
|
+
no_commands do
|
|
53
|
+
def run_hook(hook, **extra_details)
|
|
54
|
+
if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
|
|
55
|
+
details = {
|
|
56
|
+
hosts: KAMAL.hosts.join(","),
|
|
57
|
+
roles: KAMAL.specific_roles&.join(","),
|
|
58
|
+
lock: KAMAL.holding_lock?.to_s,
|
|
59
|
+
command: command,
|
|
60
|
+
subcommand: subcommand
|
|
61
|
+
}.compact
|
|
62
|
+
|
|
63
|
+
hooks_output = KAMAL.config.hooks_output_for(hook)
|
|
64
|
+
|
|
65
|
+
# CLI flags override config: -q hides all, -v shows all
|
|
66
|
+
# Config setting :verbose forces output, :quiet forces silence
|
|
67
|
+
hook_verbosity = if KAMAL.verbosity == :info && hooks_output
|
|
68
|
+
VERBOSITY.fetch(hooks_output)
|
|
69
|
+
else
|
|
70
|
+
KAMAL.verbosity
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
HOOK_MUTEX.synchronize do
|
|
74
|
+
with_env KAMAL.hook.env(**details, **extra_details) do
|
|
75
|
+
KAMAL.with_verbosity(hook_verbosity) do
|
|
76
|
+
run_locally do
|
|
77
|
+
execute *KAMAL.hook.run(hook)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
rescue SSHKit::Command::Failed => e
|
|
81
|
+
raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
47
88
|
private
|
|
48
89
|
def options_with_subcommand_class_options
|
|
49
90
|
options.merge(@_initializer.last[:class_options] || {})
|
|
@@ -221,38 +262,6 @@ module Kamal::Cli
|
|
|
221
262
|
raise
|
|
222
263
|
end
|
|
223
264
|
|
|
224
|
-
def run_hook(hook, **extra_details)
|
|
225
|
-
if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)
|
|
226
|
-
details = {
|
|
227
|
-
hosts: KAMAL.hosts.join(","),
|
|
228
|
-
roles: KAMAL.specific_roles&.join(","),
|
|
229
|
-
lock: KAMAL.holding_lock?.to_s,
|
|
230
|
-
command: command,
|
|
231
|
-
subcommand: subcommand
|
|
232
|
-
}.compact
|
|
233
|
-
|
|
234
|
-
hooks_output = KAMAL.config.hooks_output_for(hook)
|
|
235
|
-
|
|
236
|
-
# CLI flags override config: -q hides all, -v shows all
|
|
237
|
-
# Config setting :verbose forces output, :quiet forces silence
|
|
238
|
-
hook_verbosity = if KAMAL.verbosity == :info && hooks_output
|
|
239
|
-
VERBOSITY.fetch(hooks_output)
|
|
240
|
-
else
|
|
241
|
-
KAMAL.verbosity
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
with_env KAMAL.hook.env(**details, **extra_details) do
|
|
245
|
-
KAMAL.with_verbosity(hook_verbosity) do
|
|
246
|
-
run_locally do
|
|
247
|
-
execute *KAMAL.hook.run(hook)
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
rescue SSHKit::Command::Failed => e
|
|
251
|
-
raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
end
|
|
255
|
-
|
|
256
265
|
def on(*args, &block)
|
|
257
266
|
pre_connect_if_required
|
|
258
267
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Checks that only read the configuration — no SSH, no network. They report problems
|
|
2
|
+
# that are visible in deploy.yml alone, so they still run when every host is unreachable.
|
|
3
|
+
class Kamal::Cli::Doctor::ConfigChecks
|
|
4
|
+
def run
|
|
5
|
+
readiness_results
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
def result(check, target, status, detail)
|
|
10
|
+
Kamal::Cli::Doctor::Result.new(check, target, status, detail)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def readiness_results
|
|
14
|
+
KAMAL.roles.map { |role| readiness_check(role) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def readiness_check(role)
|
|
18
|
+
if role.readiness_source != :none
|
|
19
|
+
result :readiness, role.name, :ok, role.readiness_description
|
|
20
|
+
elsif role.readiness_gated?
|
|
21
|
+
# readiness_gated? with no source left is `healthcheck: false` — the gap is deliberate.
|
|
22
|
+
result :readiness, role.name, :ok, "healthcheck: false — accepted #{role.readiness_delay}s after the container starts"
|
|
23
|
+
else
|
|
24
|
+
result :readiness, role.name, :warn, "no healthcheck — the old container stops #{role.readiness_delay}s after the new one starts; " \
|
|
25
|
+
"add a `healthcheck:` block, or opt out with `healthcheck: false`"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "resolv"
|
|
2
|
+
require "socket"
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
# Local (no SSH) readiness checks for the domains kamal-proxy will serve:
|
|
6
|
+
# DNS resolution against the configured hosts and TLS certificate expiry.
|
|
7
|
+
class Kamal::Cli::Doctor::EndpointChecks
|
|
8
|
+
CERTIFICATE_EXPIRY_WARN_DAYS = 14
|
|
9
|
+
SECONDS_PER_DAY = 86_400
|
|
10
|
+
TLS_CONNECT_TIMEOUT = 5
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
dns_results + certificate_results
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def result(check, target, status, detail)
|
|
18
|
+
Kamal::Cli::Doctor::Result.new(check, target, status, detail)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Roles and accessories that run behind the proxy with custom domains.
|
|
22
|
+
def proxied_units
|
|
23
|
+
@proxied_units ||= (KAMAL.roles + KAMAL.config.proxy_accessories).select do |unit|
|
|
24
|
+
unit.running_proxy? && unit.proxy.hosts.any?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dns_results
|
|
29
|
+
proxied_units.flat_map do |unit|
|
|
30
|
+
unit.proxy.hosts.map { |domain| dns_check(domain, unit.hosts) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def certificate_results
|
|
35
|
+
proxied_units.flat_map do |unit|
|
|
36
|
+
next [] unless unit.proxy.ssl?
|
|
37
|
+
|
|
38
|
+
unit.proxy.hosts.map do |domain|
|
|
39
|
+
unit.proxy.custom_ssl_certificate? ? custom_certificate_check(unit.proxy, domain) : served_certificate_check(domain)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dns_check(domain, unit_hosts)
|
|
45
|
+
resolved = Resolv.getaddresses(domain)
|
|
46
|
+
expected = expected_ips(unit_hosts)
|
|
47
|
+
matching = resolved & expected
|
|
48
|
+
|
|
49
|
+
if resolved.empty?
|
|
50
|
+
result :dns, domain, :fail, "does not resolve"
|
|
51
|
+
elsif matching.any?
|
|
52
|
+
result :dns, domain, :ok, "resolves to #{matching.join(", ")}"
|
|
53
|
+
else
|
|
54
|
+
result :dns, domain, :warn, "resolves to #{resolved.join(", ")}, expected one of #{expected.join(", ")} (fine when fronted by a CDN or load balancer)"
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
result :dns, domain, :fail, "could not resolve (#{e.message})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def expected_ips(unit_hosts)
|
|
61
|
+
unit_hosts.flat_map do |unit_host|
|
|
62
|
+
if unit_host =~ Resolv::IPv4::Regex || unit_host =~ Resolv::IPv6::Regex
|
|
63
|
+
[ unit_host ]
|
|
64
|
+
else
|
|
65
|
+
Resolv.getaddresses(unit_host)
|
|
66
|
+
end
|
|
67
|
+
end.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def custom_certificate_check(proxy, domain)
|
|
71
|
+
pem = proxy.certificate_pem_content
|
|
72
|
+
return result(:certificate, domain, :fail, "the certificate_pem secret is missing or empty") if pem.blank?
|
|
73
|
+
|
|
74
|
+
expiry_check domain, OpenSSL::X509::Certificate.new(pem), source: "configured certificate"
|
|
75
|
+
rescue OpenSSL::X509::CertificateError => e
|
|
76
|
+
result :certificate, domain, :fail, "certificate_pem is not a valid certificate (#{e.message})"
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
result :certificate, domain, :fail, "could not check the configured certificate (#{e.message})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def served_certificate_check(domain)
|
|
82
|
+
expiry_check domain, peer_certificate(domain), source: "served certificate"
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
result :certificate, domain, :warn, "could not check TLS on #{domain}:443 (#{e.message}) - expected if the app isn't deployed yet"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def expiry_check(domain, certificate, source:)
|
|
88
|
+
days_left = ((certificate.not_after - Time.now) / SECONDS_PER_DAY).floor
|
|
89
|
+
|
|
90
|
+
if days_left.negative?
|
|
91
|
+
result :certificate, domain, :fail, "#{source} expired on #{certificate.not_after}"
|
|
92
|
+
elsif days_left < CERTIFICATE_EXPIRY_WARN_DAYS
|
|
93
|
+
result :certificate, domain, :warn, "#{source} expires in #{days_left} days (#{certificate.not_after})"
|
|
94
|
+
else
|
|
95
|
+
result :certificate, domain, :ok, "#{source} valid until #{certificate.not_after}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def peer_certificate(domain, port = 443)
|
|
100
|
+
Socket.tcp(domain, port, connect_timeout: TLS_CONNECT_TIMEOUT) do |tcp|
|
|
101
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
102
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_NONE # only inspecting expiry, so self-signed is fine
|
|
103
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
104
|
+
ssl.hostname = domain
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
ssl.connect
|
|
108
|
+
ssl.peer_cert
|
|
109
|
+
ensure
|
|
110
|
+
ssl.close
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|