dash 4.0.1 → 4.0.3
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/base.rb +27 -1
- data/lib/dash/cli/lock.rb +35 -5
- data/lib/dash/cli/proxy.rb +4 -0
- data/lib/dash/commands/base.rb +28 -0
- data/lib/dash/commands/loadbalancer.rb +19 -5
- data/lib/dash/commands/proxy.rb +11 -31
- data/lib/dash/configuration/docs/sshkit.yml +4 -0
- data/lib/dash/sshkit_with_ext.rb +44 -0
- data/lib/dash/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 563633a7568cdc7c4567bbe4957af6e5212e37b3ae99ddda0e990552f949762f
|
|
4
|
+
data.tar.gz: dda127134f2d0ef7fc4e7636ce7132106842855f9084d082a5a3b65c41313b56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ebab61a7f7f4df70ecd48e5e95415198a01990e6ae6ce93dca062775f2d11b91d2a971bcf9b0b13b4cd3aa6940700c45f8fc73bc1c50be64fe9478e2e1d7ca8
|
|
7
|
+
data.tar.gz: 5cda006f0c95a79eea3198c9caa089d5e3b3d064860b6d031efe12b65513b2d4219ce04b62b5a9c66532bbda38681ba0ec8dd6cd51143b270588a804be647488
|
data/lib/dash/cli/base.rb
CHANGED
|
@@ -217,7 +217,7 @@ module Dash::Cli
|
|
|
217
217
|
|
|
218
218
|
break
|
|
219
219
|
rescue LockHeldError
|
|
220
|
-
|
|
220
|
+
roll_back_server_lock(held)
|
|
221
221
|
|
|
222
222
|
unless details_shown
|
|
223
223
|
# The holder can release between our failed mkdir and this read.
|
|
@@ -248,12 +248,28 @@ module Dash::Cli
|
|
|
248
248
|
|
|
249
249
|
say "Waiting #{interval}s for the server lock (#{remaining}s remaining)...", :magenta
|
|
250
250
|
sleep [ interval, remaining ].min
|
|
251
|
+
rescue StandardError
|
|
252
|
+
# Anything else - a dropped SSH connection on a host that idled
|
|
253
|
+
# through the wait, a full disk - leaves the hosts we did take
|
|
254
|
+
# locked. holding_server_lock? is still false, so with_server_lock's
|
|
255
|
+
# ensure never runs and nothing else would ever release them.
|
|
256
|
+
roll_back_server_lock(held)
|
|
257
|
+
raise
|
|
251
258
|
end
|
|
252
259
|
end
|
|
253
260
|
|
|
254
261
|
DASH.holding_server_lock = true
|
|
255
262
|
end
|
|
256
263
|
|
|
264
|
+
# Never raises: on the contention path a raise here would abandon the
|
|
265
|
+
# locks it was rolling back, and on the failure path it would replace the
|
|
266
|
+
# error the operator needs to see.
|
|
267
|
+
def roll_back_server_lock(held)
|
|
268
|
+
release_server_lock_on(held)
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
say "Error releasing the server lock on #{Array(held).join(", ")}: #{e.message}", :red
|
|
271
|
+
end
|
|
272
|
+
|
|
257
273
|
def release_server_lock
|
|
258
274
|
say "Releasing the server lock...", :magenta
|
|
259
275
|
release_server_lock_on(server_lock_hosts)
|
|
@@ -264,12 +280,22 @@ module Dash::Cli
|
|
|
264
280
|
# Only ever called with hosts this process actually locked, so a missing
|
|
265
281
|
# directory means someone already cleaned up, not that we may delete
|
|
266
282
|
# another deploy's lock.
|
|
283
|
+
#
|
|
284
|
+
# Every host is attempted even after one fails - stopping at the first
|
|
285
|
+
# error would strand the remaining locks with no one left to release them
|
|
286
|
+
# - and the first error is re-raised once the sweep is done.
|
|
267
287
|
def release_server_lock_on(hosts)
|
|
288
|
+
error = nil
|
|
289
|
+
|
|
268
290
|
Array(hosts).each do |host|
|
|
269
291
|
execute_lock_release(lock: DASH.server_lock, hosts: host)
|
|
270
292
|
rescue LockMissingError
|
|
271
293
|
nil
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
error ||= e
|
|
272
296
|
end
|
|
297
|
+
|
|
298
|
+
raise error if error
|
|
273
299
|
end
|
|
274
300
|
|
|
275
301
|
def server_lock_hosts
|
data/lib/dash/cli/lock.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
class Dash::Cli::Lock < Dash::Cli::Base
|
|
2
2
|
desc "status", "Report lock status"
|
|
3
|
+
option :server, type: :boolean, default: false, desc: "Report the shared server lock instead of the deploy lock"
|
|
3
4
|
def status
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
if options[:server]
|
|
6
|
+
report_server_lock_status
|
|
7
|
+
else
|
|
8
|
+
handle_missing_lock do
|
|
9
|
+
puts capture_lock_status
|
|
10
|
+
end
|
|
6
11
|
end
|
|
7
12
|
end
|
|
8
13
|
|
|
@@ -18,10 +23,16 @@ class Dash::Cli::Lock < Dash::Cli::Base
|
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
desc "release", "Release the deploy lock"
|
|
26
|
+
option :server, type: :boolean, default: false, desc: "Release the shared server lock instead of the deploy lock"
|
|
21
27
|
def release
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
say "Released the
|
|
28
|
+
if options[:server]
|
|
29
|
+
release_server_lock_on(server_lock_hosts)
|
|
30
|
+
say "Released the server lock"
|
|
31
|
+
else
|
|
32
|
+
handle_missing_lock do
|
|
33
|
+
execute_lock_release
|
|
34
|
+
say "Released the deploy lock"
|
|
35
|
+
end
|
|
25
36
|
end
|
|
26
37
|
end
|
|
27
38
|
|
|
@@ -31,4 +42,23 @@ class Dash::Cli::Lock < Dash::Cli::Base
|
|
|
31
42
|
rescue LockMissingError
|
|
32
43
|
say "There is no deploy lock"
|
|
33
44
|
end
|
|
45
|
+
|
|
46
|
+
# The server lock is taken per host rather than per destination, so report
|
|
47
|
+
# each one: an acquire that failed part-way leaves it held on a subset.
|
|
48
|
+
def report_server_lock_status
|
|
49
|
+
server_lock_hosts.each do |host|
|
|
50
|
+
if (status = server_lock_status_on(host))
|
|
51
|
+
say "Server lock on #{host}:"
|
|
52
|
+
puts status
|
|
53
|
+
else
|
|
54
|
+
say "There is no server lock on #{host}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def server_lock_status_on(host)
|
|
60
|
+
capture_lock_status(lock: DASH.server_lock, hosts: host)
|
|
61
|
+
rescue LockMissingError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
34
64
|
end
|
data/lib/dash/cli/proxy.rb
CHANGED
|
@@ -76,6 +76,10 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
76
76
|
info "Starting loadbalancer on #{host}..."
|
|
77
77
|
execute *DASH.registry.login
|
|
78
78
|
|
|
79
|
+
# Bring a pre-rename volume across before the container can be created
|
|
80
|
+
# against an empty one. A no-op once it has been.
|
|
81
|
+
execute *DASH.loadbalancer.copy_legacy_config_volume
|
|
82
|
+
|
|
79
83
|
# The load balancer terminates TLS and owns the cache, so its host
|
|
80
84
|
# needs the proxy secrets (acme credentials, cache store) just like
|
|
81
85
|
# the proxy hosts do.
|
data/lib/dash/commands/base.rb
CHANGED
|
@@ -126,6 +126,34 @@ module Dash::Commands
|
|
|
126
126
|
[ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ]
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
# Adopts a pre-rename docker volume: creates `volume` from `legacy` if, and
|
|
130
|
+
# only if, `volume` is absent and `legacy` is present. A host with neither
|
|
131
|
+
# exits 0. The first word must be a program, never `!` — see
|
|
132
|
+
# Dash::Commands::Proxy#copy_legacy_config_volume.
|
|
133
|
+
def copy_legacy_volume(legacy:, volume:, image:)
|
|
134
|
+
any \
|
|
135
|
+
volume_exists(volume),
|
|
136
|
+
negate(volume_exists(legacy)),
|
|
137
|
+
[ "(", *combine(docker(:volume, :create, volume), copy_between_volumes(legacy, volume, image: image)), ")" ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def negate(command)
|
|
141
|
+
[ "!", *command ]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def volume_exists(name)
|
|
145
|
+
docker :volume, :inspect, name, ">", "/dev/null", "2>&1"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def copy_between_volumes(from, to, image:)
|
|
149
|
+
docker \
|
|
150
|
+
:run, "--rm", "--user", "root", "--entrypoint", "sh",
|
|
151
|
+
"--volume", "#{from}:/from",
|
|
152
|
+
"--volume", "#{to}:/to",
|
|
153
|
+
image,
|
|
154
|
+
"-c", "'cp -a /from/. /to/'"
|
|
155
|
+
end
|
|
156
|
+
|
|
129
157
|
def docker(*args)
|
|
130
158
|
args.compact.unshift :docker
|
|
131
159
|
end
|
|
@@ -37,6 +37,16 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
37
37
|
combine start, run, by: "||"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# A dedicated load balancer host went through the 4.0 rename with nothing
|
|
41
|
+
# adopting its `kamal-loadbalancer-config` volume: the per-host proxies got
|
|
42
|
+
# Dash::Cli::Proxy::LegacyRename, the load balancer got a fresh empty volume
|
|
43
|
+
# and lost its routing table, dynamic domains and ACME cache. On a shared
|
|
44
|
+
# proxy host the volume is the proxy's own and LegacyRename already copied
|
|
45
|
+
# it, so this is a no-op there.
|
|
46
|
+
def copy_legacy_config_volume
|
|
47
|
+
copy_legacy_volume(legacy: legacy_config_volume_name, volume: config_volume_name, image: loadbalancer_config.run.image)
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def deploy(targets: [])
|
|
41
51
|
docker :exec, container_name, "dash-proxy", "deploy", loadbalancer_config.config.service,
|
|
42
52
|
*loadbalancer_config.deploy_command_args(targets: targets)
|
|
@@ -196,11 +206,15 @@ class Dash::Commands::Loadbalancer < Dash::Commands::Base
|
|
|
196
206
|
# load balancer and a shared proxy host never fight over the same volume.
|
|
197
207
|
# (The apps-config mount comes with run_args, via the proxy's run surface.)
|
|
198
208
|
def config_volume
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
[ "--volume", "#{config_volume_name}:/home/dash-proxy/.config/dash-proxy" ]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def config_volume_name
|
|
213
|
+
on_proxy_host? ? Dash::Configuration::Proxy::CONFIG_VOLUME : Dash::Configuration::Proxy::LOADBALANCER_CONFIG_VOLUME
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def legacy_config_volume_name
|
|
217
|
+
on_proxy_host? ? Dash::Configuration::Proxy::LEGACY_CONFIG_VOLUME : Dash::Configuration::Proxy::LEGACY_LOADBALANCER_CONFIG_VOLUME
|
|
204
218
|
end
|
|
205
219
|
|
|
206
220
|
# The certificate store lives in whichever config volume this loadbalancer
|
data/lib/dash/commands/proxy.rb
CHANGED
|
@@ -51,21 +51,18 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
51
51
|
# boot sequence, and its ubuntu base has sh and cp. `--user root` because the
|
|
52
52
|
# image's own user cannot write the destination volume; `cp -a` preserves the
|
|
53
53
|
# uid, which the rename leaves at 1001.
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
# `
|
|
58
|
-
#
|
|
59
|
-
#
|
|
54
|
+
#
|
|
55
|
+
# Shape: `exists || ! legacy_exists || ( create && copy )`. The chain has to
|
|
56
|
+
# start with a real program: SSHKit prefixes the first word with
|
|
57
|
+
# `/usr/bin/env`, and `env !` is "No such file or directory" (exit 127), not
|
|
58
|
+
# shell negation. 4.0.0 led with `! docker volume inspect …`, so the whole
|
|
59
|
+
# chain failed silently into `|| true`, the proxy booted onto a volume docker
|
|
60
|
+
# created empty, and every host lost its routing table and ACME cache on the
|
|
61
|
+
# first deploy. The subshell groups create-and-copy because `&&` and `||`
|
|
62
|
+
# share precedence and associate left — without it a host that already has
|
|
63
|
+
# the new volume would still run the copy over live state.
|
|
60
64
|
def copy_legacy_config_volume(volume: Dash::Configuration::Proxy::CONFIG_VOLUME, legacy: Dash::Configuration::Proxy::LEGACY_CONFIG_VOLUME)
|
|
61
|
-
|
|
62
|
-
combine(
|
|
63
|
-
negate(volume_exists(volume)),
|
|
64
|
-
volume_exists(legacy),
|
|
65
|
-
docker(:volume, :create, volume),
|
|
66
|
-
copy_between_volumes(legacy, volume)
|
|
67
|
-
),
|
|
68
|
-
[ :true ]
|
|
65
|
+
copy_legacy_volume(legacy: legacy, volume: volume, image: proxy_image)
|
|
69
66
|
end
|
|
70
67
|
|
|
71
68
|
# Stops and removes a pre-rename proxy container so the renamed one can claim
|
|
@@ -326,27 +323,10 @@ class Dash::Commands::Proxy < Dash::Commands::Base
|
|
|
326
323
|
[ "--label", "#{CONFIG_DIGEST_LABEL}=#{digest}" ] if digest
|
|
327
324
|
end
|
|
328
325
|
|
|
329
|
-
def negate(command)
|
|
330
|
-
[ "!", *command ]
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def volume_exists(name)
|
|
334
|
-
docker :volume, :inspect, name, ">", "/dev/null", "2>&1"
|
|
335
|
-
end
|
|
336
|
-
|
|
337
326
|
def container_exists(name)
|
|
338
327
|
docker :container, :inspect, name, ">", "/dev/null", "2>&1"
|
|
339
328
|
end
|
|
340
329
|
|
|
341
|
-
def copy_between_volumes(from, to)
|
|
342
|
-
docker \
|
|
343
|
-
:run, "--rm", "--user", "root", "--entrypoint", "sh",
|
|
344
|
-
"--volume", "#{from}:/from",
|
|
345
|
-
"--volume", "#{to}:/to",
|
|
346
|
-
proxy_image,
|
|
347
|
-
"-c", "'cp -a /from/. /to/'"
|
|
348
|
-
end
|
|
349
|
-
|
|
350
330
|
# The image the volume copy borrows. The proxy this gem is pinned to is
|
|
351
331
|
# already pulled by the time the copy runs, and `rake release` gates on
|
|
352
332
|
# MINIMUM_VERSION being published, so this is always resolvable — unlike
|
|
@@ -20,6 +20,10 @@ sshkit:
|
|
|
20
20
|
#
|
|
21
21
|
# Kamal sets a long idle timeout of 900 seconds on connections to try to avoid
|
|
22
22
|
# re-connection storms after an idle period, such as building an image or waiting for CI.
|
|
23
|
+
#
|
|
24
|
+
# A pooled connection that the network dropped while idle is evicted and the command
|
|
25
|
+
# retried once on a fresh connection, so a long wait elsewhere (a server-lock poll, a
|
|
26
|
+
# loadbalancer reboot) does not fail the next command on another host.
|
|
23
27
|
pool_idle_timeout: 300
|
|
24
28
|
|
|
25
29
|
# DNS retry settings
|
data/lib/dash/sshkit_with_ext.rb
CHANGED
|
@@ -170,6 +170,50 @@ class SSHKit::Backend::Netssh
|
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
prepend LimitConcurrentStartsInstance
|
|
173
|
+
|
|
174
|
+
# A pooled session that sat idle while dash was busy elsewhere (a server-lock
|
|
175
|
+
# wait, a loadbalancer reboot) gets dropped by NATs and cloud networks without
|
|
176
|
+
# either end noticing: net-ssh only sends keepalives from inside its event
|
|
177
|
+
# loop, and the pool's liveness probe is a non-blocking `process(0)`, so the
|
|
178
|
+
# session looks fine until the first command on it dies. Evict that session
|
|
179
|
+
# and rerun the block once on a fresh connection.
|
|
180
|
+
#
|
|
181
|
+
# The block is rerun, so a drop *mid-command* runs the command twice. That
|
|
182
|
+
# is accepted: the errors here are the idle-drop ones, where nothing reached
|
|
183
|
+
# the server, and the alternative is the deploy failing on the next host.
|
|
184
|
+
module ReconnectOnStaleConnection
|
|
185
|
+
STALE_CONNECTION_ERRORS = [ Errno::ECONNRESET, Errno::EPIPE, Net::SSH::Disconnect, Net::SSH::Timeout ].freeze
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
def with_ssh
|
|
189
|
+
reconnected = false
|
|
190
|
+
|
|
191
|
+
begin
|
|
192
|
+
super do |ssh|
|
|
193
|
+
yield ssh
|
|
194
|
+
rescue *STALE_CONNECTION_ERRORS
|
|
195
|
+
evict_stale_session(ssh)
|
|
196
|
+
raise
|
|
197
|
+
end
|
|
198
|
+
rescue *STALE_CONNECTION_ERRORS => e
|
|
199
|
+
raise if reconnected
|
|
200
|
+
|
|
201
|
+
reconnected = true
|
|
202
|
+
SSHKit.config.output.warn("Reconnecting to #{host}: #{e.message}")
|
|
203
|
+
retry
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `close` waits for channel-close acknowledgements, which never come over
|
|
208
|
+
# a dead socket. `shutdown!` just closes the socket, and a closed session
|
|
209
|
+
# is what makes the pool drop it instead of caching it again.
|
|
210
|
+
def evict_stale_session(ssh)
|
|
211
|
+
ssh.shutdown!
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
prepend ReconnectOnStaleConnection
|
|
173
217
|
end
|
|
174
218
|
|
|
175
219
|
class SSHKit::Runner::Parallel
|
data/lib/dash/version.rb
CHANGED