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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32799cf5de0b149a2b36f88e0af2d671c82a5d05a3c54180f034d8574eb58bf8
4
- data.tar.gz: 1e33d017d9ec6e3e3545ea0c1125d9d4b38a2e12557197b2c827e00e608b4aa0
3
+ metadata.gz: bb6c271cec9b9ab118649ffeaf503aa5ba31bd8d0f8057ce0a61adb93e130ef4
4
+ data.tar.gz: ce7f60b8e4a764e06ab868663f825134d8f73cac0e11f663793e6a12471ab490
5
5
  SHA512:
6
- metadata.gz: bac661834cb626277175f2e0a9abbfb273975a0e70dd45779352798f547c5097ff836cfaed017316a207d59aa4d62b1b337112692db9fa7870aec2056bcd14e4
7
- data.tar.gz: a8879e8f7b3250903eb854f23436b63892cdced0a303969039ebc809f29cce5231f3dda51faa2c5d3755b55edb8b543a007f96964272dc7afe1a1cf6c132ec6b
6
+ metadata.gz: 35204403e1680a42ce0b0726225d68c55119a74b387a9cd45189822499e5aa225c250ad82190944cbd4a5ff7aa976b3dcf02600e44b92a8a4ed165f86123c96b
7
+ data.tar.gz: cb520eb7f9028fad75e4537afcdcc1c7aea71aa6cb58c35a33b9c8d185781a4de9ad7a9f3d1f1f8daba8b73390b279bbd292ac1a82d531a303f34086c69c28d3
@@ -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
- output = capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false).to_s
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
- execute *app.run(hostname: hostname)
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 = capture_with_info(*app.container_id_for_version(version)).strip
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) { health_status } }
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
- # An exec probe is docker-invisible the container declares no healthcheck, so
92
- # `docker inspect` would only ever report its state. Poll the probe instead.
93
- def health_status
94
- role.healthcheck&.exec? ? exec_probe_status : capture_with_info(*app.status(version: version))
95
- end
96
-
97
- def exec_probe_status
98
- execute *app.health_probe(version: version)
99
- "healthy"
100
- rescue SSHKit::Command::Failed
101
- "exec probe exited non-zero"
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
- versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n")
190
- versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ]
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
@@ -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 = mirror_hosts).any?
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
- def mirror_hosts
229
- if DASH.app_hosts.many?
230
- mirror_hosts = Concurrent::Hash.new
231
- on(DASH.app_hosts) do |host|
232
- first_mirror = capture_with_info(*DASH.builder.first_mirror).strip.presence
233
- mirror_hosts[first_mirror] ||= host.to_s if first_mirror
234
- rescue SSHKit::Command::Failed => e
235
- raise unless e.message =~ /error calling index: reflect: slice index out of range/
236
- end
237
- mirror_hosts.values
238
- else
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
- status == "healthy" || (unchecked?(status) && docker_state(status) == "running")
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
@@ -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
- capture_with_info(*proxy.container_id, raise_on_non_zero_exit: false).strip.present?
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
- capture_with_info(*proxy.config_digest, raise_on_non_zero_exit: false).strip
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
- bridge_network
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
- execute *proxy.ensure_apps_config_directory
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?
@@ -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. A no-op once it has been.
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
- version = capture_with_info(*proxy.version).strip.presence
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. A no-op
81
- # once it has been.
82
- execute *DASH.docker.connect_legacy_network_containers
83
- execute *DASH.loadbalancer.copy_legacy_config_volume
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
- container_id = capture_with_info(*DASH.loadbalancer.container_id, raise_on_non_zero_exit: false).strip
109
- current_digest = capture_with_info(*DASH.loadbalancer.config_digest, raise_on_non_zero_exit: false).strip
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 container_id.present? && current_digest != DASH.loadbalancer_config.run_config_digest
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
@@ -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 returns. A container id is hex and a version is
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
@@ -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
- [ "(", *combine(docker(:volume, :create, volume), copy_between_volumes(legacy, volume, image: image)), ")" ]
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
@@ -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
- CONFIG_DIGEST_FORMAT = "'{{ with index .Config.Labels \"#{CONFIG_DIGEST_LABEL}\" }}{{ . }}" \
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. Kamal `docker exec`s it from
111
- # the deploy host on every poll and gates the deploy on the exit code — no HTTP
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 poll costs an SSH round trip plus a process spawn (~100-300ms), which
119
- # rules out sub-second polling.
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. Polling
124
- # follows the deploy's own backoff and gives up at `deploy_timeout`; a probe that
125
- # never exits zero fails the boot and leaves the old container running.
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
@@ -1,3 +1,3 @@
1
1
  module Dash
2
- VERSION = "4.1.0"
2
+ VERSION = "4.1.1"
3
3
  end
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.0
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