dash 4.1.0 → 4.1.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/dash/cli/app/boot.rb +28 -16
- data/lib/dash/cli/app.rb +2 -2
- data/lib/dash/cli/build.rb +22 -15
- data/lib/dash/cli/healthcheck/poller.rb +15 -3
- data/lib/dash/cli/healthcheck/progress_reporter.rb +39 -0
- data/lib/dash/cli/proxy/drift.rb +17 -2
- data/lib/dash/cli/proxy/legacy_rename.rb +8 -21
- data/lib/dash/cli/proxy/loadbalancer_reboot.rb +8 -1
- data/lib/dash/cli/proxy/reboot.rb +6 -1
- data/lib/dash/cli/proxy.rb +19 -12
- data/lib/dash/commands/app.rb +65 -2
- data/lib/dash/commands/base.rb +42 -1
- data/lib/dash/commands/loadbalancer.rb +54 -0
- data/lib/dash/commands/proxy/state.rb +31 -0
- data/lib/dash/commands/proxy.rb +74 -2
- data/lib/dash/commands/registry.rb +14 -0
- data/lib/dash/configuration/docs/role.yml +7 -7
- data/lib/dash/configuration/proxy.rb +3 -0
- data/lib/dash/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb6c271cec9b9ab118649ffeaf503aa5ba31bd8d0f8057ce0a61adb93e130ef4
|
|
4
|
+
data.tar.gz: ce7f60b8e4a764e06ab868663f825134d8f73cac0e11f663793e6a12471ab490
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35204403e1680a42ce0b0726225d68c55119a74b387a9cd45189822499e5aa225c250ad82190944cbd4a5ff7aa976b3dcf02600e44b92a8a4ed165f86123c96b
|
|
7
|
+
data.tar.gz: cb520eb7f9028fad75e4537afcdcc1c7aea71aa6cb58c35a33b9c8d185781a4de9ad7a9f3d1f1f8daba8b73390b279bbd292ac1a82d531a303f34086c69c28d3
|
data/lib/dash/cli/app/boot.rb
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
class Dash::Cli::App::Boot
|
|
2
|
+
# What `docker container ls --quiet` prints, and so what dash-proxy has always been
|
|
3
|
+
# handed as a target. `docker run --detach` prints the full 64-character id, so the
|
|
4
|
+
# target is its first twelve characters rather than a round trip of its own.
|
|
5
|
+
SHORT_CONTAINER_ID_LENGTH = 12
|
|
6
|
+
|
|
2
7
|
attr_reader :host, :role, :version, :barrier, :sshkit, :cli
|
|
3
8
|
delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
|
|
4
9
|
delegate :run_hook, to: :cli
|
|
@@ -58,8 +63,7 @@ class Dash::Cli::App::Boot
|
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def capture_boot_state
|
|
61
|
-
|
|
62
|
-
clashing, _, running = output.partition(/^#{Regexp.escape(Dash::Commands::App::BOOT_STATE_SEPARATOR)}$/)
|
|
66
|
+
clashing, running = Dash::Commands::App.split_state(capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false))
|
|
63
67
|
|
|
64
68
|
[ clashing.strip.presence, running.strip.presence ]
|
|
65
69
|
end
|
|
@@ -70,9 +74,13 @@ class Dash::Cli::App::Boot
|
|
|
70
74
|
execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
|
|
71
75
|
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
# `docker run --detach` prints the id of the container it just started, so the
|
|
78
|
+
# proxy target comes out of the run itself — asking docker for it again was a round
|
|
79
|
+
# trip spent re-reading something the host had already said.
|
|
80
|
+
container_id = capture_with_info(*app.run(hostname: hostname)).strip
|
|
81
|
+
|
|
74
82
|
if running_proxy?
|
|
75
|
-
endpoint =
|
|
83
|
+
endpoint = container_id[0, SHORT_CONTAINER_ID_LENGTH]
|
|
76
84
|
raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
|
|
77
85
|
|
|
78
86
|
run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
|
|
@@ -80,7 +88,7 @@ class Dash::Cli::App::Boot
|
|
|
80
88
|
timing_healthy { execute *app.deploy(target: endpoint) }
|
|
81
89
|
run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
|
|
82
90
|
else
|
|
83
|
-
timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role)
|
|
91
|
+
timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role, &method(:readiness_status)) }
|
|
84
92
|
end
|
|
85
93
|
rescue => e
|
|
86
94
|
error "Failed to boot #{role} on #{host}"
|
|
@@ -88,17 +96,21 @@ class Dash::Cli::App::Boot
|
|
|
88
96
|
raise e
|
|
89
97
|
end
|
|
90
98
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
# A role behind the proxy lets `dash-proxy deploy` block on the host until the
|
|
100
|
+
# container is healthy; a role without one now does the same, waiting in a shell loop
|
|
101
|
+
# on the host that streams its progress back rather than being polled from here once
|
|
102
|
+
# per attempt. The poller asks for the wait, and — only for an unchecked container it
|
|
103
|
+
# has just let through its readiness delay — for a plain confirming read.
|
|
104
|
+
#
|
|
105
|
+
# Neither capture suppresses a non-zero exit: a status that cannot be read is a broken
|
|
106
|
+
# command, and it has always failed the boot on the spot rather than being waited out.
|
|
107
|
+
def readiness_status(mode, seconds_left = nil)
|
|
108
|
+
if mode == :confirm
|
|
109
|
+
capture_with_info(*app.status(version: version))
|
|
110
|
+
else
|
|
111
|
+
capture_with_info *app.wait_for_ready(version: version, timeout: seconds_left),
|
|
112
|
+
interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
|
|
113
|
+
end
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
# Every failed boot gets the container log, and the health probe history when the
|
data/lib/dash/cli/app.rb
CHANGED
|
@@ -186,8 +186,8 @@ class Dash::Cli::App < Dash::Cli::Base
|
|
|
186
186
|
with_lock_if_stopping do
|
|
187
187
|
on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
|
|
188
188
|
app = DASH.app(role: role, host: host)
|
|
189
|
-
|
|
190
|
-
versions
|
|
189
|
+
listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
|
|
190
|
+
versions = listed.strip.split("\n") - [ running.strip ]
|
|
191
191
|
|
|
192
192
|
versions.each do |version|
|
|
193
193
|
if stop
|
data/lib/dash/cli/build.rb
CHANGED
|
@@ -74,10 +74,8 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
74
74
|
|
|
75
75
|
desc "pull", "Pull app image from registry onto servers"
|
|
76
76
|
def pull
|
|
77
|
-
login_to_registry_remotely unless DASH.registry.local?
|
|
78
|
-
|
|
79
77
|
forward_local_registry_port(DASH.hosts, **DASH.config.ssh.options) do
|
|
80
|
-
if (first_hosts =
|
|
78
|
+
if (first_hosts = login_and_mirror_hosts).any?
|
|
81
79
|
# Pull on a single host per mirror first to seed them
|
|
82
80
|
say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
|
|
83
81
|
pull_on_hosts(first_hosts)
|
|
@@ -225,19 +223,28 @@ class Dash::Cli::Build < Dash::Cli::Base
|
|
|
225
223
|
end
|
|
226
224
|
end
|
|
227
225
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
[]
|
|
226
|
+
# The registry login and the mirror probe share one round trip per host. The probe only
|
|
227
|
+
# earns its keep where there is more than one app host to seed, so on a single host the
|
|
228
|
+
# login goes on its own; a local registry needs no login and the fold is the probe alone.
|
|
229
|
+
#
|
|
230
|
+
# A host with no mirror configured fails the `docker info` half with docker's own index
|
|
231
|
+
# error, which is what "no mirror" looks like. Anything else still raises - a rejected
|
|
232
|
+
# login short-circuits the `&&` and comes back with docker's `unauthorized`/`denied`,
|
|
233
|
+
# which this rescue does not match.
|
|
234
|
+
def login_and_mirror_hosts
|
|
235
|
+
unless DASH.app_hosts.many?
|
|
236
|
+
login_to_registry_remotely unless DASH.registry.local?
|
|
237
|
+
return []
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
mirror_hosts = Concurrent::Hash.new
|
|
241
|
+
on(DASH.app_hosts) do |host|
|
|
242
|
+
first_mirror = capture_with_info(*DASH.registry.login_then(DASH.builder.first_mirror)).strip.presence
|
|
243
|
+
mirror_hosts[first_mirror] ||= host.to_s if first_mirror
|
|
244
|
+
rescue SSHKit::Command::Failed => e
|
|
245
|
+
raise unless e.message =~ /error calling index: reflect: slice index out of range/
|
|
240
246
|
end
|
|
247
|
+
mirror_hosts.values
|
|
241
248
|
end
|
|
242
249
|
|
|
243
250
|
# Audit, clean and pull share one round trip. validate_image keeps its own: folding it
|
|
@@ -3,13 +3,19 @@ module Dash::Cli::Healthcheck::Poller
|
|
|
3
3
|
|
|
4
4
|
NO_HEALTHCHECK = Dash::Commands::Base::NO_HEALTHCHECK
|
|
5
5
|
|
|
6
|
+
# The wait itself happens on the host now (Dash::Commands::App#wait_for_ready), which
|
|
7
|
+
# returns the moment the status is one this poller accepts and otherwise waits out the
|
|
8
|
+
# deadline it is given. So the block is called once for the wait - and once more only to
|
|
9
|
+
# confirm an unchecked container is still running after its readiness delay. Every
|
|
10
|
+
# decision below is the one the client-side poll made, in the same words; what shrank is
|
|
11
|
+
# the number of round trips it took to reach them.
|
|
6
12
|
def wait_for_healthy(role:, &block)
|
|
7
13
|
attempt = 1
|
|
8
14
|
timeout_at = Time.now + DASH.config.deploy_timeout
|
|
9
15
|
readiness_delay = role.readiness_delay
|
|
10
16
|
|
|
11
17
|
begin
|
|
12
|
-
status = block.call
|
|
18
|
+
status = block.call(:wait, seconds_left(timeout_at))
|
|
13
19
|
|
|
14
20
|
if unchecked?(status)
|
|
15
21
|
ensure_no_healthcheck_drift(role, status)
|
|
@@ -20,7 +26,7 @@ module Dash::Cli::Healthcheck::Poller
|
|
|
20
26
|
# Wait for the readiness delay and confirm it is still running
|
|
21
27
|
if readiness_delay > 0
|
|
22
28
|
sleep readiness_delay
|
|
23
|
-
status = block.call
|
|
29
|
+
status = block.call(:confirm)
|
|
24
30
|
ensure_no_healthcheck_drift(role, status)
|
|
25
31
|
end
|
|
26
32
|
end
|
|
@@ -55,8 +61,14 @@ module Dash::Cli::Healthcheck::Poller
|
|
|
55
61
|
status.to_s.delete_prefix("#{NO_HEALTHCHECK}:")
|
|
56
62
|
end
|
|
57
63
|
|
|
64
|
+
# Shared with the host-side wait, which stops looking on exactly these - see
|
|
65
|
+
# Dash::Commands::Base::READY_STATUSES for why the two have to agree.
|
|
58
66
|
def acceptable?(status)
|
|
59
|
-
|
|
67
|
+
Dash::Commands::Base::READY_STATUSES.include?(status)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def seconds_left(timeout_at)
|
|
71
|
+
[ (timeout_at - Time.now).ceil, 0 ].max
|
|
60
72
|
end
|
|
61
73
|
|
|
62
74
|
# The config asked docker to probe this container and docker is not probing it — the flags
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Turns the server-side readiness wait's progress lines back into the beacon the
|
|
2
|
+
# client-side poll used to print. It is an SSHKit interaction handler, so it sees the
|
|
3
|
+
# wait's stderr as it streams — the operator gets the same once-a-second feedback they
|
|
4
|
+
# got when the laptop was the thing doing the polling, for one round trip instead of one
|
|
5
|
+
# per attempt. The cadence is fixed at a second because the host loop's is.
|
|
6
|
+
#
|
|
7
|
+
# The stream is line-oriented but arrives in chunks (the SSH backend splits on packet
|
|
8
|
+
# boundaries, not newlines), so data is buffered and only whole lines are reported. Only
|
|
9
|
+
# stderr is buffered: the wait's stdout carries the final status, and stdout and stderr are
|
|
10
|
+
# separate SSH streams whose chunks can interleave — folding both into one buffer would let
|
|
11
|
+
# the status land in the middle of a half-arrived progress line and corrupt them both.
|
|
12
|
+
# Anything on stderr that is not a progress line (docker's own complaints) is ignored.
|
|
13
|
+
class Dash::Cli::Healthcheck::ProgressReporter
|
|
14
|
+
LINE = /\A#{Regexp.escape(Dash::Commands::Base::READINESS_PROGRESS_PREFIX)} (?<elapsed>\d+) (?<left>\d+)(?: |\z)/
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@buffer = +""
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# SSHKit's interaction-handler contract.
|
|
22
|
+
def on_data(_command, stream_name, data, _channel = nil)
|
|
23
|
+
return unless stream_name == :stderr
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@buffer << data.to_s
|
|
27
|
+
while (newline = @buffer.index("\n"))
|
|
28
|
+
report @buffer.slice!(0..newline).chomp
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
def report(line)
|
|
35
|
+
match = LINE.match(line) or return
|
|
36
|
+
|
|
37
|
+
SSHKit.config.output.info "Container not ready yet, retrying in 1s (#{match[:elapsed]}s elapsed, #{match[:left]}s left)"
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/dash/cli/proxy/drift.rb
CHANGED
|
@@ -7,8 +7,23 @@ class Dash::Cli::Proxy::Drift
|
|
|
7
7
|
@sshkit = sshkit
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# One `docker inspect` for everything a boot asks about the running proxy: whether it
|
|
11
|
+
# exists, which image tag it runs, and the digest it was booted with. Captured once per
|
|
12
|
+
# instance - `dash proxy boot` reads all three off it, and `dash doctor` only the first.
|
|
13
|
+
def state
|
|
14
|
+
@state ||= Dash::Commands::Proxy::State.parse(
|
|
15
|
+
capture_with_info(*proxy.inspect_state, raise_on_non_zero_exit: false)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
10
19
|
def container_exists?
|
|
11
|
-
|
|
20
|
+
state.exists?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The tag the running proxy was booted from, for the minimum-version gate. Nil when
|
|
24
|
+
# nothing is running - a host with no proxy has no version to be too old.
|
|
25
|
+
def version
|
|
26
|
+
state.version
|
|
12
27
|
end
|
|
13
28
|
|
|
14
29
|
# A proxy container has drifted when it was started with a different config
|
|
@@ -30,7 +45,7 @@ class Dash::Cli::Proxy::Drift
|
|
|
30
45
|
|
|
31
46
|
private
|
|
32
47
|
def current_digest
|
|
33
|
-
|
|
48
|
+
state.digest.to_s
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
def proxy
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
# so a second deploy is a no-op. Nothing here removes the legacy network or
|
|
24
24
|
# volume: an operator who wants them gone removes them by hand, and stage 3d
|
|
25
25
|
# deletes this class outright.
|
|
26
|
+
#
|
|
27
|
+
# All three travel as one command (Dash::Commands::Proxy#prepare_boot), guarded on a
|
|
28
|
+
# marker the host writes once it is verifiably past the rename - so a migrated host, and
|
|
29
|
+
# a host installed fresh on 4.x that never had a kamal-proxy, run no docker command here
|
|
30
|
+
# at all. The round trip itself is one the host already pays: the command carries the
|
|
31
|
+
# apps-config `mkdir -p` too, which reads nothing the bridge writes. Stage 3d keeps the
|
|
32
|
+
# mkdir and deletes the rest.
|
|
26
33
|
class Dash::Cli::Proxy::LegacyRename
|
|
27
34
|
attr_reader :host, :sshkit
|
|
28
35
|
delegate :execute, to: :sshkit
|
|
@@ -33,26 +40,6 @@ class Dash::Cli::Proxy::LegacyRename
|
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
def run
|
|
36
|
-
|
|
37
|
-
adopt_config_volume
|
|
38
|
-
replace_legacy_container
|
|
43
|
+
execute *DASH.proxy(host).prepare_boot
|
|
39
44
|
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
def bridge_network
|
|
43
|
-
execute *DASH.docker.connect_legacy_network_containers
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def adopt_config_volume
|
|
47
|
-
execute *DASH.proxy(host).copy_legacy_config_volume
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# The drain timeout the proxy is configured with, so a busy host is not cut
|
|
51
|
-
# off mid-request any more abruptly than a normal reboot would.
|
|
52
|
-
def replace_legacy_container
|
|
53
|
-
proxy = DASH.proxy(host)
|
|
54
|
-
|
|
55
|
-
execute *proxy.remove_legacy_container(timeout: DASH.config.drain_timeout)
|
|
56
|
-
execute *proxy.remove_legacy_holder_container
|
|
57
|
-
end
|
|
58
45
|
end
|
|
@@ -21,6 +21,14 @@ class Dash::Cli::Proxy::LoadbalancerReboot
|
|
|
21
21
|
execute *DASH.registry.login
|
|
22
22
|
ensure_network
|
|
23
23
|
|
|
24
|
+
# After the network the bridge attaches the legacy network's containers to,
|
|
25
|
+
# and before anything that could create the new container or let `docker
|
|
26
|
+
# run --volume` create dash-loadbalancer-config empty: adopt the legacy
|
|
27
|
+
# volume's routing table, dynamic domains and ACME cache. Carries the
|
|
28
|
+
# apps-config mkdir this reboot paid a round trip for anyway
|
|
29
|
+
# (zoolutions/dash#168, see Dash::Commands::Loadbalancer#legacy_rename).
|
|
30
|
+
execute *DASH.loadbalancer.prepare_boot
|
|
31
|
+
|
|
24
32
|
info "Stopping and removing #{DASH.loadbalancer.container_name} on #{host}, if running..."
|
|
25
33
|
execute *DASH.loadbalancer.stop, raise_on_non_zero_exit: false
|
|
26
34
|
execute *DASH.loadbalancer.remove_container
|
|
@@ -32,7 +40,6 @@ class Dash::Cli::Proxy::LoadbalancerReboot
|
|
|
32
40
|
execute *DASH.loadbalancer.remove_proxy_secrets_file, raise_on_non_zero_exit: false
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
execute *DASH.loadbalancer.ensure_apps_config_directory
|
|
36
43
|
Dash::Cli::Proxy::LoadbalancerClaim.new(host, sshkit).claim_run_config(replace: true)
|
|
37
44
|
execute *DASH.loadbalancer.run
|
|
38
45
|
|
|
@@ -37,7 +37,12 @@ class Dash::Cli::Proxy::Reboot
|
|
|
37
37
|
|
|
38
38
|
def replace_container
|
|
39
39
|
execute *proxy.ensure_proxy_directory
|
|
40
|
-
|
|
40
|
+
# Before the new container - and the new config volume `docker run
|
|
41
|
+
# --volume` would otherwise create empty - exists: bring a host still on
|
|
42
|
+
# pre-rename identity across. Carries the apps-config mkdir this reboot
|
|
43
|
+
# paid a round trip for anyway, so the bridge costs none here either
|
|
44
|
+
# (zoolutions/dash#168, see Dash::Cli::Proxy::LegacyRename).
|
|
45
|
+
execute *proxy.prepare_boot
|
|
41
46
|
sync_proxy_secrets
|
|
42
47
|
|
|
43
48
|
if proxy.port_holder?
|
data/lib/dash/cli/proxy.rb
CHANGED
|
@@ -22,7 +22,9 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
22
22
|
execute *DASH.registry.login
|
|
23
23
|
|
|
24
24
|
# Before anything reads the new container, volume or network: bring a
|
|
25
|
-
# host still on pre-rename identity across
|
|
25
|
+
# host still on pre-rename identity across, and make the apps-config
|
|
26
|
+
# directory in the same round trip. Nothing but a `test -f` once it has
|
|
27
|
+
# been - see Dash::Cli::Proxy::LegacyRename.
|
|
26
28
|
Dash::Cli::Proxy::LegacyRename.new(host, self).run
|
|
27
29
|
|
|
28
30
|
proxy = DASH.proxy(host)
|
|
@@ -34,7 +36,8 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
34
36
|
else
|
|
35
37
|
stale_hosts << host.to_s if drift.drifted?
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
# The tag off the inspect the drift check already made, not a read of its own.
|
|
40
|
+
version = drift.version
|
|
38
41
|
|
|
39
42
|
if version && Dash::Utils.older_version?(version, Dash::Configuration::Proxy::Run::MINIMUM_VERSION)
|
|
40
43
|
raise "dash-proxy version #{version} is too old, run `dash proxy reboot` in order to update to at least #{Dash::Configuration::Proxy::Run::MINIMUM_VERSION}"
|
|
@@ -48,7 +51,6 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
48
51
|
execute *proxy.remove_proxy_secrets_file, raise_on_non_zero_exit: false
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
execute *proxy.ensure_apps_config_directory
|
|
52
54
|
execute *proxy.start_holder_or_run if proxy.port_holder?
|
|
53
55
|
execute *proxy.start_or_run(digest: drift.expected_digest)
|
|
54
56
|
end
|
|
@@ -77,10 +79,10 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
77
79
|
execute *DASH.registry.login
|
|
78
80
|
|
|
79
81
|
# Bring a pre-rename host across before the container can be created
|
|
80
|
-
# against an empty volume or a network nothing else joined
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
execute *DASH.loadbalancer.
|
|
82
|
+
# against an empty volume or a network nothing else joined, and make
|
|
83
|
+
# the apps-config directory in the same round trip. Nothing but a
|
|
84
|
+
# `test -f` once it has been.
|
|
85
|
+
execute *DASH.loadbalancer.prepare_boot
|
|
84
86
|
|
|
85
87
|
# The load balancer terminates TLS and owns the cache, so its host
|
|
86
88
|
# needs the proxy secrets (acme credentials, cache store) just like
|
|
@@ -92,8 +94,6 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
92
94
|
execute *DASH.loadbalancer.remove_proxy_secrets_file, raise_on_non_zero_exit: false
|
|
93
95
|
end
|
|
94
96
|
|
|
95
|
-
execute *DASH.loadbalancer.ensure_apps_config_directory
|
|
96
|
-
|
|
97
97
|
# TLS terminates at the load balancer, so the TLS material the app
|
|
98
98
|
# hosts get - custom certificates and the mTLS client CA - must
|
|
99
99
|
# reach this host too; the LB container reads it through the same
|
|
@@ -105,10 +105,11 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
105
105
|
# The same drift detection the proxy hosts get: a loadbalancer booted
|
|
106
106
|
# with a different config digest reboots below (or warns, when
|
|
107
107
|
# automatic reboot is off) instead of serving a stale config forever.
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
state = Dash::Commands::Proxy::State.parse(
|
|
109
|
+
capture_with_info(*DASH.loadbalancer.inspect_state, raise_on_non_zero_exit: false)
|
|
110
|
+
)
|
|
110
111
|
|
|
111
|
-
if
|
|
112
|
+
if state.exists? && state.digest.to_s != DASH.loadbalancer_config.run_config_digest
|
|
112
113
|
if auto_reboot
|
|
113
114
|
# Leave the old loadbalancer serving until its reboot below.
|
|
114
115
|
lb_drifted << host.to_s
|
|
@@ -413,6 +414,12 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
413
414
|
if DASH.config.proxy.load_balancing?
|
|
414
415
|
on(DASH.config.proxy.effective_loadbalancer) do |host|
|
|
415
416
|
execute *DASH.registry.login
|
|
417
|
+
# start_or_run falls through to `docker run` on a host with no
|
|
418
|
+
# container, so this is a volume-creating path too and gets the same
|
|
419
|
+
# bridge boot and reboot do (zoolutions/dash#168). It also makes the
|
|
420
|
+
# apps-config directory the bind mount would otherwise have docker
|
|
421
|
+
# create root-owned.
|
|
422
|
+
execute *DASH.loadbalancer.prepare_boot
|
|
416
423
|
execute *DASH.loadbalancer.start_or_run
|
|
417
424
|
end
|
|
418
425
|
else
|
data/lib/dash/commands/app.rb
CHANGED
|
@@ -3,10 +3,16 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
3
3
|
|
|
4
4
|
ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]
|
|
5
5
|
|
|
6
|
-
# Separates the two answers #boot_state
|
|
7
|
-
# a name suffix, so neither can produce this line on its own.
|
|
6
|
+
# Separates the two answers #boot_state and #stale_state return. A container id is hex
|
|
7
|
+
# and a version is a name suffix, so neither can produce this line on its own.
|
|
8
8
|
BOOT_STATE_SEPARATOR = "--%--"
|
|
9
9
|
|
|
10
|
+
# The two halves of a #boot_state or #stale_state capture, raw. Callers decide what an
|
|
11
|
+
# empty half means; the separator line itself is dropped.
|
|
12
|
+
def self.split_state(output)
|
|
13
|
+
output.to_s.partition(/^#{Regexp.escape(BOOT_STATE_SEPARATOR)}$/).values_at(0, 2)
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
attr_reader :role, :host
|
|
11
17
|
|
|
12
18
|
delegate :container_name, to: :role
|
|
@@ -54,6 +60,31 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
54
60
|
docker :exec, container_name(version), *shell([ role.healthcheck.exec ])
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
# Waits on the host for the container to reach a status the poller accepts, so a boot
|
|
64
|
+
# pays one round trip for the wait however long the container takes to come up - the
|
|
65
|
+
# client-side poll paid one per attempt. Prints the status it stopped on to stdout: the
|
|
66
|
+
# moment it sees one of READY_STATUSES, or the last one it saw when the deadline passes.
|
|
67
|
+
# Progress goes to stderr once a second in between. Waiting through every other status is
|
|
68
|
+
# deliberate: docker reports a container `unhealthy` after three failed probes, which for
|
|
69
|
+
# an app slower than that is a state it recovers from.
|
|
70
|
+
#
|
|
71
|
+
# Reaching the deadline exits 0, because it is an answer - the poller phrases it. Only a
|
|
72
|
+
# status that could not be read at all exits non-zero, which is a broken command and
|
|
73
|
+
# SSHKit's to raise, exactly as it was when the read was a round trip of its own.
|
|
74
|
+
def wait_for_ready(version:, timeout:)
|
|
75
|
+
shell [
|
|
76
|
+
"started=$(date +%s);",
|
|
77
|
+
"while true; do",
|
|
78
|
+
*readiness_probe(version: version),
|
|
79
|
+
"case \"$status\" in #{READY_STATUSES.join("|")}) echo \"$status\"; exit 0;; esac;",
|
|
80
|
+
"elapsed=$(( $(date +%s) - started ));",
|
|
81
|
+
"if [ \"$elapsed\" -ge #{timeout.to_i} ]; then echo \"$status\"; exit 0; fi;",
|
|
82
|
+
"echo \"#{READINESS_PROGRESS_PREFIX} $elapsed $(( #{timeout.to_i} - elapsed )) $status\" 1>&2;",
|
|
83
|
+
"sleep 1;",
|
|
84
|
+
"done"
|
|
85
|
+
]
|
|
86
|
+
end
|
|
87
|
+
|
|
57
88
|
def stop(version: nil)
|
|
58
89
|
pipe \
|
|
59
90
|
version ? container_id_for_version(version) : current_running_container_id,
|
|
@@ -93,6 +124,16 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
93
124
|
current_running_version
|
|
94
125
|
end
|
|
95
126
|
|
|
127
|
+
# Everything the stale check needs from a host: every version of the role that has a
|
|
128
|
+
# container, and the version running now - the difference is what is stale. Same shape
|
|
129
|
+
# as #boot_state, same separator, same reason for `;` over `&&`.
|
|
130
|
+
def stale_state
|
|
131
|
+
chain \
|
|
132
|
+
list_versions,
|
|
133
|
+
[ :echo, BOOT_STATE_SEPARATOR ],
|
|
134
|
+
current_running_version
|
|
135
|
+
end
|
|
136
|
+
|
|
96
137
|
def list_versions(*docker_args, statuses: nil)
|
|
97
138
|
pipe \
|
|
98
139
|
docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
|
|
@@ -104,6 +145,28 @@ class Dash::Commands::App < Dash::Commands::Base
|
|
|
104
145
|
end
|
|
105
146
|
|
|
106
147
|
private
|
|
148
|
+
# The same two readiness sources #status and #health_probe cover, read into `$status`
|
|
149
|
+
# so the loop around them is the same either way. They differ in what a non-zero exit
|
|
150
|
+
# means. A probe that exits non-zero IS the answer "not ready", so its output is
|
|
151
|
+
# discarded and the loop goes on; an inspect that produced no answer at all - docker is
|
|
152
|
+
# unreachable, or the container is gone - takes the whole command down with it, with
|
|
153
|
+
# docker's complaint on stderr for SSHKit to put in the exception.
|
|
154
|
+
#
|
|
155
|
+
# An empty status is checked as well as the exit code, because the exit code alone is
|
|
156
|
+
# not portable: the read is a pipeline, so its status is xargs', and a `docker container
|
|
157
|
+
# ls` that failed pipes nothing. GNU xargs then runs `docker inspect` with no container
|
|
158
|
+
# and exits 123, but BSD and BusyBox xargs skip the utility entirely and exit 0. Both
|
|
159
|
+
# leave `$status` empty, and empty is not something a working `docker inspect --format`
|
|
160
|
+
# can print.
|
|
161
|
+
def readiness_probe(version:)
|
|
162
|
+
if role.healthcheck&.exec?
|
|
163
|
+
[ "if", *health_probe(version: version), ">/dev/null 2>&1;", "then status=healthy;", "else status=\"#{EXEC_PROBE_FAILED}\";", "fi;" ]
|
|
164
|
+
else
|
|
165
|
+
[ "status=#{substitute(*status(version: version))} || exit $?;",
|
|
166
|
+
"if [ -z \"$status\" ]; then echo \"could not read the status of #{container_name(version)}\" 1>&2; exit 1; fi;" ]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
107
170
|
def latest_image_id
|
|
108
171
|
docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
|
|
109
172
|
end
|
data/lib/dash/commands/base.rb
CHANGED
|
@@ -10,6 +10,22 @@ module Dash::Commands
|
|
|
10
10
|
|
|
11
11
|
DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}#{NO_HEALTHCHECK}:{{.State.Status}}{{end}}'"
|
|
12
12
|
|
|
13
|
+
# The statuses a boot accepts as ready. Dash::Cli::Healthcheck::Poller decides what a
|
|
14
|
+
# status means; Dash::Commands::App#wait_for_ready only decides when to stop looking,
|
|
15
|
+
# and it stops on exactly these. The two must agree: a status the host loop returned
|
|
16
|
+
# early for that the poller would not accept fails a boot the old client-side poll
|
|
17
|
+
# would have waited out.
|
|
18
|
+
READY_STATUSES = [ "healthy", "#{NO_HEALTHCHECK}:running" ].freeze
|
|
19
|
+
|
|
20
|
+
# What a `healthcheck: exec:` probe reports when it exits non-zero. Produced by the
|
|
21
|
+
# host-side wait, read back by the poller, so it is a wire format, not a message.
|
|
22
|
+
EXEC_PROBE_FAILED = "exec probe exited non-zero"
|
|
23
|
+
|
|
24
|
+
# The line #wait_for_ready prints to stderr on every attempt, read back by
|
|
25
|
+
# Dash::Cli::Healthcheck::ProgressReporter. stderr, because a capture returns stdout
|
|
26
|
+
# alone - which keeps the captured value the final status and nothing else.
|
|
27
|
+
READINESS_PROGRESS_PREFIX = "dash-readiness"
|
|
28
|
+
|
|
13
29
|
attr_accessor :config
|
|
14
30
|
|
|
15
31
|
def initialize(config)
|
|
@@ -24,6 +40,18 @@ module Dash::Commands
|
|
|
24
40
|
docker :container, :ls, *("--all" unless only_running), "--filter", "'name=^#{container_name}$'", "--quiet"
|
|
25
41
|
end
|
|
26
42
|
|
|
43
|
+
# True only when `list_command`'s own output is confirmed empty - never inferred from
|
|
44
|
+
# a failure. `docker container inspect name > /dev/null 2>&1` (negated) cannot tell
|
|
45
|
+
# "no such container" from "the daemon could not be asked" - both exit non-zero - so a
|
|
46
|
+
# transient failure there reads as confirmed absence. A `list` exits 0 whichever way
|
|
47
|
+
# the match went and non-zero only on a genuine failure, so `result=$(list) && [ -z
|
|
48
|
+
# "$result" ]` fails closed: `result=$(list)` carries list's own exit status (POSIX;
|
|
49
|
+
# verified against sh and bash), so a failed list stops the chain before the test runs.
|
|
50
|
+
# `list_command` must be a listing (docker container/volume ls), never an inspect.
|
|
51
|
+
def confirmed_empty?(list_command)
|
|
52
|
+
[ "result=$(#{list_command.join(" ")})", "&&", "[", "-z", "\"$result\"", "]" ]
|
|
53
|
+
end
|
|
54
|
+
|
|
27
55
|
def make_directory_for(remote_file)
|
|
28
56
|
make_directory Pathname.new(remote_file).dirname.to_s
|
|
29
57
|
end
|
|
@@ -98,6 +126,13 @@ module Dash::Commands
|
|
|
98
126
|
combine *commands, by: ";"
|
|
99
127
|
end
|
|
100
128
|
|
|
129
|
+
# One subshell around an && chain. Composing two builders that each mix && and ||
|
|
130
|
+
# cannot be done flat - the operators share precedence and associate left, so the
|
|
131
|
+
# second builder's guards re-associate across the first one's.
|
|
132
|
+
def group(*commands)
|
|
133
|
+
[ "(", *combine(*commands), ")" ]
|
|
134
|
+
end
|
|
135
|
+
|
|
101
136
|
def pipe(*commands)
|
|
102
137
|
combine *commands, by: "|"
|
|
103
138
|
end
|
|
@@ -134,13 +169,19 @@ module Dash::Commands
|
|
|
134
169
|
any \
|
|
135
170
|
volume_exists(volume),
|
|
136
171
|
negate(volume_exists(legacy)),
|
|
137
|
-
|
|
172
|
+
group(docker(:volume, :create, volume), copy_between_volumes(legacy, volume, image: image))
|
|
138
173
|
end
|
|
139
174
|
|
|
140
175
|
def negate(command)
|
|
141
176
|
[ "!", *command ]
|
|
142
177
|
end
|
|
143
178
|
|
|
179
|
+
# The docker builders (network create, the stage-3c network bridge) for callers that
|
|
180
|
+
# compose them into a command of their own rather than executing them on their own.
|
|
181
|
+
def docker_commands
|
|
182
|
+
@docker_commands ||= Dash::Commands::Docker.new(config)
|
|
183
|
+
end
|
|
184
|
+
|
|
144
185
|
def volume_exists(name)
|
|
145
186
|
docker :volume, :inspect, name, ">", "/dev/null", "2>&1"
|
|
146
187
|
end
|
|
@@ -47,6 +47,28 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
47
47
|
copy_legacy_volume(legacy: legacy_config_volume_name, volume: config_volume_name, image: loadbalancer_config.run.image)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Everything this host needs before anything reads its container, volume or network,
|
|
51
|
+
# in the one round trip it already pays for the apps-config directory. Same shape as
|
|
52
|
+
# Dash::Commands::Proxy#prepare_boot, including why the guard has to be the first word.
|
|
53
|
+
def prepare_boot
|
|
54
|
+
combine legacy_rename, ensure_apps_config_directory
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The loadbalancer's half of the stage-3c bridge - network, then volume - skipped
|
|
58
|
+
# outright by a host that has already been through it. No legacy container is replaced
|
|
59
|
+
# here (a dedicated loadbalancer host never ran one under a name this gem knows), so
|
|
60
|
+
# the marker is verified on the volume instead: the new one exists, or there was never
|
|
61
|
+
# a legacy one to adopt. Stage 3d deletes this with the rest of the bridge.
|
|
62
|
+
def legacy_rename
|
|
63
|
+
any \
|
|
64
|
+
[ :test, "-f", legacy_rename_marker ],
|
|
65
|
+
group(
|
|
66
|
+
group(docker_commands.connect_legacy_network_containers),
|
|
67
|
+
group(copy_legacy_config_volume),
|
|
68
|
+
group(any(mark_legacy_renamed, [ :true ]))
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
50
72
|
def deploy(targets: [])
|
|
51
73
|
docker :exec, container_name, "dash-proxy", "deploy", loadbalancer_config.config.service,
|
|
52
74
|
*loadbalancer_config.deploy_command_args(targets: targets)
|
|
@@ -76,6 +98,12 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
76
98
|
docker :inspect, container_name, "--format", Dash::Commands::Proxy::CONFIG_DIGEST_FORMAT
|
|
77
99
|
end
|
|
78
100
|
|
|
101
|
+
# One read for container id, image tag and config digest - parsed by
|
|
102
|
+
# Dash::Commands::Proxy::State, same as the per-host proxy's.
|
|
103
|
+
def inspect_state
|
|
104
|
+
docker :inspect, container_name, "--format", Dash::Commands::Proxy::STATE_FORMAT
|
|
105
|
+
end
|
|
106
|
+
|
|
79
107
|
def container_id(only_running: false)
|
|
80
108
|
container_id_for(container_name: container_name, only_running: only_running)
|
|
81
109
|
end
|
|
@@ -163,6 +191,32 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
163
191
|
end
|
|
164
192
|
|
|
165
193
|
private
|
|
194
|
+
# Stage 3c. 3d deletes both of these with the rest of the bridge.
|
|
195
|
+
def legacy_rename_marker
|
|
196
|
+
File.join loadbalancer_config.directory, Dash::Configuration::Proxy::LEGACY_RENAME_MARKER
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Verified on the volume existing, not on a container being gone - the loadbalancer
|
|
200
|
+
# replaces no legacy container, so this is the only signal its bridge has. That makes
|
|
201
|
+
# it foolable in one specific way: if `dash-loadbalancer-config` comes to exist before
|
|
202
|
+
# this bridge ever runs on a host, the marker is written despite the legacy volume's
|
|
203
|
+
# routing table and ACME cache never having been copied. The fix is upstream of the
|
|
204
|
+
# heuristic rather than in it - every path that can create the container, and with it
|
|
205
|
+
# the volume `docker run --volume` auto-creates empty, runs the bridge first:
|
|
206
|
+
# `boot`, both reboots (Dash::Cli::Proxy::Reboot, Dash::Cli::Proxy::LoadbalancerReboot)
|
|
207
|
+
# and `dash proxy loadbalancer start` (zoolutions/dash#168).
|
|
208
|
+
#
|
|
209
|
+
# If a host is somehow in that state anyway - an operator's own `docker run`, a
|
|
210
|
+
# volume created by hand - recovery is to copy the legacy volume's contents over,
|
|
211
|
+
# then remove .legacy-renamed under this host's loadbalancer directory so this
|
|
212
|
+
# re-evaluates.
|
|
213
|
+
def mark_legacy_renamed
|
|
214
|
+
combine \
|
|
215
|
+
group(any(volume_exists(config_volume_name), negate(volume_exists(legacy_config_volume_name)))),
|
|
216
|
+
make_directory(loadbalancer_config.directory),
|
|
217
|
+
[ :touch, legacy_rename_marker ]
|
|
218
|
+
end
|
|
219
|
+
|
|
166
220
|
def run_args
|
|
167
221
|
loadbalancer_config.run_args
|
|
168
222
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# What one `docker inspect` of a proxy container tells a boot: whether it exists, which
|
|
2
|
+
# image tag it runs, and the config digest it was booted with. Three questions that used
|
|
3
|
+
# to cost three round trips each (`container_id`, `version`, `config_digest`).
|
|
4
|
+
#
|
|
5
|
+
# Produced by Dash::Commands::Proxy#inspect_state and its loadbalancer twin, both of which
|
|
6
|
+
# are captured with raise_on_non_zero_exit: false - a host with no container inspects to
|
|
7
|
+
# empty output, which parses to a state that simply does not exist.
|
|
8
|
+
class Dash::Commands::Proxy::State
|
|
9
|
+
attr_reader :id, :image, :digest
|
|
10
|
+
|
|
11
|
+
def self.parse(output)
|
|
12
|
+
id, image, digest = output.to_s.strip.split(" ", 3)
|
|
13
|
+
new(id: id, image: image, digest: digest)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(id: nil, image: nil, digest: nil)
|
|
17
|
+
@id = id.presence
|
|
18
|
+
@image = image.presence
|
|
19
|
+
@digest = digest.presence
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?
|
|
23
|
+
id.present?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The tag, read the way Dash::Commands::Proxy#version reads it - everything past the
|
|
27
|
+
# LAST colon, so a registry host carrying a port does not get mistaken for the version.
|
|
28
|
+
def version
|
|
29
|
+
image&.split(":")&.last
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/dash/commands/proxy.rb
CHANGED
|
@@ -12,8 +12,13 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
12
12
|
# Both the legacy constant and the fallback go away in stage 3d.
|
|
13
13
|
LEGACY_CONFIG_DIGEST_LABEL = "org.kamal.proxy-config-digest"
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
"{{ else }}{{ index .Config.Labels \"#{LEGACY_CONFIG_DIGEST_LABEL}\" }}{{ end }}
|
|
15
|
+
CONFIG_DIGEST_TEMPLATE = "{{ with index .Config.Labels \"#{CONFIG_DIGEST_LABEL}\" }}{{ . }}" \
|
|
16
|
+
"{{ else }}{{ index .Config.Labels \"#{LEGACY_CONFIG_DIGEST_LABEL}\" }}{{ end }}"
|
|
17
|
+
|
|
18
|
+
CONFIG_DIGEST_FORMAT = "'#{CONFIG_DIGEST_TEMPLATE}'"
|
|
19
|
+
|
|
20
|
+
# Everything Dash::Cli::Proxy::Drift and the minimum-version gate need, in one format.
|
|
21
|
+
STATE_FORMAT = "'{{.Id}} {{.Config.Image}} #{CONFIG_DIGEST_TEMPLATE}'"
|
|
17
22
|
|
|
18
23
|
def initialize(config, host:)
|
|
19
24
|
super(config)
|
|
@@ -42,6 +47,41 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
42
47
|
# destination not existing, so a second deploy is a no-op. Stage 3d deletes
|
|
43
48
|
# them along with the legacy constants they read.
|
|
44
49
|
|
|
50
|
+
# Everything a proxy host needs before anything reads its container, volume or
|
|
51
|
+
# network, in the one round trip it already pays for the apps-config directory.
|
|
52
|
+
#
|
|
53
|
+
# `a || b && c` is `(a || b) && c`, so the mkdir runs whichever way the guard went -
|
|
54
|
+
# and the guard has to be the first word rather than a parenthesised group, because
|
|
55
|
+
# SSHKit prefixes the first word with /usr/bin/env and passes only `test` through.
|
|
56
|
+
# Stage 3d drops the legacy_rename half and leaves the mkdir.
|
|
57
|
+
def prepare_boot
|
|
58
|
+
combine legacy_rename, ensure_apps_config_directory
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The whole stage-3c bridge as one command, skipped outright by a host that has
|
|
62
|
+
# already been through it - or was installed fresh on 4.x and never had a kamal-proxy.
|
|
63
|
+
# The three steps keep their own bodies and their documented order (see
|
|
64
|
+
# Dash::Cli::Proxy::LegacyRename); each is wrapped in its own subshell because they
|
|
65
|
+
# all mix && and || at one precedence level, and composing them flat would
|
|
66
|
+
# re-associate across the volume copy's guard - the chain 4.0.0 got wrong.
|
|
67
|
+
#
|
|
68
|
+
# The marker is written on verified absence of both legacy containers, never on the
|
|
69
|
+
# chain's exit status: the two removals end in `|| true`, so a host whose stop failed
|
|
70
|
+
# would otherwise record itself as migrated and never retry. Its own `|| true` keeps
|
|
71
|
+
# that failure as quiet as it is today, while a failed volume copy still exits
|
|
72
|
+
# non-zero through the && chain and aborts the boot exactly as it does now.
|
|
73
|
+
def legacy_rename
|
|
74
|
+
any \
|
|
75
|
+
[ :test, "-f", legacy_rename_marker ],
|
|
76
|
+
group(
|
|
77
|
+
group(docker_commands.connect_legacy_network_containers),
|
|
78
|
+
group(copy_legacy_config_volume),
|
|
79
|
+
group(remove_legacy_container(timeout: config.drain_timeout)),
|
|
80
|
+
group(remove_legacy_holder_container),
|
|
81
|
+
group(any(mark_legacy_renamed, [ :true ]))
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
45
85
|
# Copies the pre-rename config volume into the new one, before anything
|
|
46
86
|
# starts. The volume holds the routing table and the ACME account and
|
|
47
87
|
# certificate cache; losing it means re-issuing every certificate and
|
|
@@ -61,6 +101,16 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
61
101
|
# first deploy. The subshell groups create-and-copy because `&&` and `||`
|
|
62
102
|
# share precedence and associate left — without it a host that already has
|
|
63
103
|
# the new volume would still run the copy over live state.
|
|
104
|
+
#
|
|
105
|
+
# The source volume is still mounted by the legacy container while this runs -
|
|
106
|
+
# deliberately, on every path (see Dash::Cli::Proxy::LegacyRename's step order), and
|
|
107
|
+
# safe because every writer into it renames into place: the routing table through
|
|
108
|
+
# writeFileAtomic, the dynamic domain and redirect state through their own temp +
|
|
109
|
+
# rename, the response cache through CreateTemp + Rename, and the ACME cache through
|
|
110
|
+
# autocert.DirCache. `cp -a` reads a complete file either way. Skew across files is
|
|
111
|
+
# possible and harmless - an unused certificate, or a route whose certificate reissues -
|
|
112
|
+
# and --recheck-targets-on-restore re-verifies the targets on the way back up
|
|
113
|
+
# (zoolutions/dash#169 review).
|
|
64
114
|
def copy_legacy_config_volume(volume: Dash::Configuration::Proxy::CONFIG_VOLUME, legacy: Dash::Configuration::Proxy::LEGACY_CONFIG_VOLUME)
|
|
65
115
|
copy_legacy_volume(legacy: legacy, volume: volume, image: proxy_image)
|
|
66
116
|
end
|
|
@@ -116,6 +166,13 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
116
166
|
docker :inspect, container_name, "--format", CONFIG_DIGEST_FORMAT
|
|
117
167
|
end
|
|
118
168
|
|
|
169
|
+
# One read for container id, image tag and config digest - parsed by
|
|
170
|
+
# Dash::Commands::Proxy::State. Capture it with raise_on_non_zero_exit: false;
|
|
171
|
+
# a host with no proxy container inspects to nothing, which is an answer.
|
|
172
|
+
def inspect_state
|
|
173
|
+
docker :inspect, container_name, "--format", STATE_FORMAT
|
|
174
|
+
end
|
|
175
|
+
|
|
119
176
|
def container_id(only_running: false)
|
|
120
177
|
container_id_for(container_name: container_name, only_running: only_running)
|
|
121
178
|
end
|
|
@@ -327,6 +384,21 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
327
384
|
docker :container, :inspect, name, ">", "/dev/null", "2>&1"
|
|
328
385
|
end
|
|
329
386
|
|
|
387
|
+
# Stage 3c. 3d deletes both of these with the rest of the bridge.
|
|
388
|
+
def legacy_rename_marker
|
|
389
|
+
File.join config.proxy_boot.host_directory, Dash::Configuration::Proxy::LEGACY_RENAME_MARKER
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# confirmed_empty?, not a negated inspect: a docker error while checking must never
|
|
393
|
+
# read as "confirmed gone" (zoolutions/dash#167 review).
|
|
394
|
+
def mark_legacy_renamed
|
|
395
|
+
combine \
|
|
396
|
+
confirmed_empty?(container_id_for(container_name: Dash::Configuration::Proxy::LEGACY_CONTAINER_NAME)),
|
|
397
|
+
confirmed_empty?(container_id_for(container_name: Dash::Configuration::Proxy::LEGACY_HOLDER_CONTAINER_NAME)),
|
|
398
|
+
make_directory(config.proxy_boot.host_directory),
|
|
399
|
+
[ :touch, legacy_rename_marker ]
|
|
400
|
+
end
|
|
401
|
+
|
|
330
402
|
# The image the volume copy borrows. The proxy this gem is pinned to is
|
|
331
403
|
# already pulled by the time the copy runs, and `rake release` gates on
|
|
332
404
|
# MINIMUM_VERSION being published, so this is always resolvable — unlike
|
|
@@ -13,6 +13,20 @@ class Dash::Commands::Registry < Dash::Commands::Base
|
|
|
13
13
|
"-p", sensitive(Dash::Utils.escape_shell_value(registry_config.password))
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The login and whatever has to happen after it on the same host, in one round trip.
|
|
17
|
+
# `docker login` prints "Login Succeeded" to stdout, so its output is redirected away:
|
|
18
|
+
# a caller that captures this gets the folded command's answer and nothing else. A local
|
|
19
|
+
# registry needs no login at all, and the fold collapses to the commands alone.
|
|
20
|
+
#
|
|
21
|
+
# The credentials stay wrapped in sensitive(...) - composing keeps the array elements
|
|
22
|
+
# intact, so SSHKit redacts them here exactly as it does for a standalone login.
|
|
23
|
+
def login_then(*commands, registry_config: nil)
|
|
24
|
+
login = login(registry_config: registry_config)
|
|
25
|
+
login = [ *login, ">", "/dev/null" ] if login
|
|
26
|
+
|
|
27
|
+
combine login, *commands
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def logout(registry_config: nil)
|
|
17
31
|
registry_config ||= config.registry
|
|
18
32
|
|
|
@@ -107,22 +107,22 @@ servers:
|
|
|
107
107
|
# A `healthcheck` cannot be combined with `health-*` keys under `options`.
|
|
108
108
|
#
|
|
109
109
|
# `exec` is the escape hatch for an image whose HEALTHCHECK you cannot change,
|
|
110
|
-
# or for an emergency override without a rebuild.
|
|
111
|
-
#
|
|
110
|
+
# or for an emergency override without a rebuild. Dash `docker exec`s it on the
|
|
111
|
+
# deploy host once a second and gates the deploy on the exit code — no HTTP
|
|
112
112
|
# server and no published port needed, and unlike `cmd` it may use `${...}`,
|
|
113
113
|
# which is quoted through to the container. It is strictly worse than `cmd` in
|
|
114
114
|
# the general case, so reach for it only when `cmd` is not available:
|
|
115
115
|
#
|
|
116
116
|
# - deploy-time only. Docker never runs it, so `docker ps` never shows
|
|
117
117
|
# `(healthy)` and `docker inspect` keeps no probe history.
|
|
118
|
-
# - each
|
|
119
|
-
#
|
|
118
|
+
# - each attempt costs a process spawn on the host (the whole wait is one SSH
|
|
119
|
+
# round trip, so the cost does not grow with how long the boot takes).
|
|
120
120
|
# - nothing outside a deploy ever runs it.
|
|
121
121
|
#
|
|
122
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.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
123
|
+
# be combined with `cmd`, `port`, `path`, or any of the duration keys. The wait
|
|
124
|
+
# gives up at `deploy_timeout`; a probe that never exits zero fails the boot and
|
|
125
|
+
# leaves the old container running.
|
|
126
126
|
#
|
|
127
127
|
# A non-proxied role with neither a `healthcheck` nor a `health-cmd` option
|
|
128
128
|
# warns on every deploy, because the readiness delay is the only thing standing
|
|
@@ -23,6 +23,9 @@ class Dash::Configuration::Proxy
|
|
|
23
23
|
LEGACY_LOADBALANCER_CONTAINER_NAME = "kamal-loadbalancer"
|
|
24
24
|
LEGACY_HOLDER_CONTAINER_NAME = "kamal-proxy-net"
|
|
25
25
|
LEGACY_NETWORK = "kamal"
|
|
26
|
+
# Written into the run directory once a host is verifiably past the 3c rename, so the
|
|
27
|
+
# bridge costs it nothing but a `test -f` on every deploy after. Deleted in stage 3d.
|
|
28
|
+
LEGACY_RENAME_MARKER = ".legacy-renamed"
|
|
26
29
|
LEGACY_CONFIG_VOLUME = "kamal-proxy-config"
|
|
27
30
|
LEGACY_LOADBALANCER_CONFIG_VOLUME = "kamal-loadbalancer-config"
|
|
28
31
|
LEGACY_IMAGE_TITLE = "kamal-proxy"
|
data/lib/dash/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dash
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.1.
|
|
4
|
+
version: 4.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -201,6 +201,7 @@ files:
|
|
|
201
201
|
- lib/dash/cli/healthcheck/drift_error.rb
|
|
202
202
|
- lib/dash/cli/healthcheck/error.rb
|
|
203
203
|
- lib/dash/cli/healthcheck/poller.rb
|
|
204
|
+
- lib/dash/cli/healthcheck/progress_reporter.rb
|
|
204
205
|
- lib/dash/cli/lock.rb
|
|
205
206
|
- lib/dash/cli/main.rb
|
|
206
207
|
- lib/dash/cli/main/migrate.rb
|
|
@@ -259,6 +260,7 @@ files:
|
|
|
259
260
|
- lib/dash/commands/lock.rb
|
|
260
261
|
- lib/dash/commands/proxy.rb
|
|
261
262
|
- lib/dash/commands/proxy/cert_transfer.rb
|
|
263
|
+
- lib/dash/commands/proxy/state.rb
|
|
262
264
|
- lib/dash/commands/prune.rb
|
|
263
265
|
- lib/dash/commands/registry.rb
|
|
264
266
|
- lib/dash/commands/server.rb
|