dash 4.0.8 → 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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/build/progress_parser.rb +136 -0
  3. data/lib/dash/build/report.rb +104 -0
  4. data/lib/dash/build/step.rb +49 -0
  5. data/lib/dash/cli/app/boot.rb +46 -24
  6. data/lib/dash/cli/app.rb +4 -4
  7. data/lib/dash/cli/base.rb +117 -2
  8. data/lib/dash/cli/build.rb +83 -20
  9. data/lib/dash/cli/doctor/config_checks.rb +36 -1
  10. data/lib/dash/cli/doctor.rb +2 -1
  11. data/lib/dash/cli/healthcheck/poller.rb +15 -3
  12. data/lib/dash/cli/healthcheck/progress_reporter.rb +39 -0
  13. data/lib/dash/cli/main.rb +24 -7
  14. data/lib/dash/cli/proxy/drift.rb +17 -2
  15. data/lib/dash/cli/proxy/legacy_rename.rb +8 -21
  16. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +8 -1
  17. data/lib/dash/cli/proxy/reboot.rb +6 -1
  18. data/lib/dash/cli/proxy.rb +19 -12
  19. data/lib/dash/cli/prune.rb +5 -8
  20. data/lib/dash/cli/report.rb +97 -0
  21. data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
  22. data/lib/dash/commander.rb +10 -2
  23. data/lib/dash/commands/app.rb +81 -0
  24. data/lib/dash/commands/auditor.rb +10 -0
  25. data/lib/dash/commands/base.rb +42 -1
  26. data/lib/dash/commands/builder/base.rb +18 -0
  27. data/lib/dash/commands/builder.rb +1 -1
  28. data/lib/dash/commands/loadbalancer.rb +54 -0
  29. data/lib/dash/commands/proxy/state.rb +31 -0
  30. data/lib/dash/commands/proxy.rb +74 -2
  31. data/lib/dash/commands/registry.rb +14 -0
  32. data/lib/dash/configuration/docs/configuration.yml +6 -0
  33. data/lib/dash/configuration/docs/report.yml +39 -0
  34. data/lib/dash/configuration/docs/role.yml +7 -7
  35. data/lib/dash/configuration/proxy.rb +3 -0
  36. data/lib/dash/configuration/report.rb +65 -0
  37. data/lib/dash/configuration.rb +2 -1
  38. data/lib/dash/dockerfile/analyzer.rb +66 -0
  39. data/lib/dash/dockerfile/context.rb +147 -0
  40. data/lib/dash/dockerfile/dockerignore.rb +29 -0
  41. data/lib/dash/dockerfile/document.rb +28 -0
  42. data/lib/dash/dockerfile/finding.rb +20 -0
  43. data/lib/dash/dockerfile/hadolint.rb +75 -0
  44. data/lib/dash/dockerfile/instruction.rb +58 -0
  45. data/lib/dash/dockerfile/parser.rb +199 -0
  46. data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
  47. data/lib/dash/dockerfile/rules/base.rb +44 -0
  48. data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
  49. data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
  50. data/lib/dash/dockerfile/rules/context_size.rb +20 -0
  51. data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
  52. data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
  53. data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
  54. data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
  55. data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
  56. data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
  57. data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
  58. data/lib/dash/dockerfile/rules/root_user.rb +12 -0
  59. data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
  60. data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
  61. data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
  62. data/lib/dash/dockerfile/stage.rb +65 -0
  63. data/lib/dash/otel_shipper.rb +5 -4
  64. data/lib/dash/output/otel_logger.rb +52 -0
  65. data/lib/dash/report/history.rb +94 -0
  66. data/lib/dash/report/trends.rb +129 -0
  67. data/lib/dash/report/writer.rb +142 -0
  68. data/lib/dash/report.rb +170 -0
  69. data/lib/dash/sshkit_with_ext.rb +62 -0
  70. data/lib/dash/timings.rb +163 -10
  71. data/lib/dash/utils.rb +7 -0
  72. data/lib/dash/version.rb +1 -1
  73. data/lib/dash.rb +4 -0
  74. metadata +38 -1
@@ -37,6 +37,9 @@ class Dash::Cli::Build < Dash::Cli::Base
37
37
  say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow
38
38
  end
39
39
 
40
+ parser = build_progress_parser
41
+ handler = handler_for(parser)
42
+
40
43
  forward_local_registry_port_for_remote_builder do
41
44
  with_env(DASH.config.builder.secrets) do
42
45
  run_locally do
@@ -60,19 +63,19 @@ class Dash::Cli::Build < Dash::Cli::Base
60
63
  push = DASH.builder.push(cli.options[:output], no_cache: cli.options[:no_cache])
61
64
 
62
65
  DASH.with_verbosity(:debug) do
63
- Dir.chdir(DASH.config.builder.build_directory) { execute *push, env: DASH.builder.push_env }
66
+ Dir.chdir(DASH.config.builder.build_directory) { execute *push, env: DASH.builder.push_env, **handler }
64
67
  end
65
68
  end
66
69
  end
67
70
  end
71
+ ensure
72
+ record_build_report parser
68
73
  end
69
74
 
70
75
  desc "pull", "Pull app image from registry onto servers"
71
76
  def pull
72
- login_to_registry_remotely unless DASH.registry.local?
73
-
74
77
  forward_local_registry_port(DASH.hosts, **DASH.config.ssh.options) do
75
- if (first_hosts = mirror_hosts).any?
78
+ if (first_hosts = login_and_mirror_hosts).any?
76
79
  #  Pull on a single host per mirror first to seed them
77
80
  say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
78
81
  pull_on_hosts(first_hosts)
@@ -147,17 +150,66 @@ class Dash::Cli::Build < Dash::Cli::Base
147
150
  say
148
151
  end
149
152
 
153
+ parser = build_progress_parser
154
+ handler = handler_for(parser)
155
+
150
156
  with_env(DASH.config.builder.secrets) do
151
157
  run_locally do
152
158
  build = DASH.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache])
153
159
  DASH.with_verbosity(:debug) do
154
- execute(*build)
160
+ execute(*build, **handler)
155
161
  end
156
162
  end
157
163
  end
164
+ ensure
165
+ # `dev` builds the working directory even when `push` would clone, so the advice reads
166
+ # the Dockerfile here rather than in a clone that may not exist.
167
+ record_build_report parser, build_directory: "."
158
168
  end
159
169
 
160
170
  private
171
+ # Buildpacks print a total and nothing else, so there is nothing for the parser to
172
+ # read and no reason to attach it.
173
+ def build_progress_parser
174
+ Dash::Build::ProgressParser.new unless DASH.builder.pack?
175
+ end
176
+
177
+ def handler_for(parser)
178
+ parser ? { interaction_handler: parser } : {}
179
+ end
180
+
181
+ # Runs whether the build succeeded or failed: a partial report naming the step that
182
+ # broke is exactly what an operator wants from a failed build. Measurement must never
183
+ # be the reason a build fails, so nothing in here is allowed to raise.
184
+ def record_build_report(parser, build_directory: DASH.config.builder.build_directory)
185
+ return unless parser
186
+
187
+ guarded_report do
188
+ parser.finish
189
+ DASH.report.build = parser.result
190
+
191
+ say "Deploy report unavailable: #{parser.error.class}: #{parser.error.message}", :yellow if parser.error
192
+
193
+ # Inside a deploy the phase table prints these at the end and the deploy runs its
194
+ # own analysis once the image is delivered. Standalone, this is the only chance.
195
+ unless DASH.report.build_entry
196
+ print_build_report
197
+ DASH.report.analyze!(DASH.config, build_directory: build_directory)
198
+ puts DASH.report.advice_lines
199
+ end
200
+ end
201
+ end
202
+
203
+ # A standalone `dash build push` has no phase table to sit under, but a CI pipeline
204
+ # that splits build from deploy should still see where the build time went.
205
+ def print_build_report
206
+ rows = DASH.report.build_lines
207
+ return if rows.empty?
208
+
209
+ puts " Build"
210
+ puts rows
211
+ end
212
+
161
213
  def connect_to_remote_host(remote_host)
162
214
  remote_uri = URI.parse(remote_host)
163
215
  if remote_uri.scheme == "ssh"
@@ -171,26 +223,37 @@ class Dash::Cli::Build < Dash::Cli::Base
171
223
  end
172
224
  end
173
225
 
174
- def mirror_hosts
175
- if DASH.app_hosts.many?
176
- mirror_hosts = Concurrent::Hash.new
177
- on(DASH.app_hosts) do |host|
178
- first_mirror = capture_with_info(*DASH.builder.first_mirror).strip.presence
179
- mirror_hosts[first_mirror] ||= host.to_s if first_mirror
180
- rescue SSHKit::Command::Failed => e
181
- raise unless e.message =~ /error calling index: reflect: slice index out of range/
182
- end
183
- mirror_hosts.values
184
- else
185
- []
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/
186
246
  end
247
+ mirror_hosts.values
187
248
  end
188
249
 
250
+ # Audit, clean and pull share one round trip. validate_image keeps its own: folding it
251
+ # in would put the pull under validate_image's trailing `|| (echo ... && exit 1)`, and
252
+ # a failed pull would then report a missing service label.
189
253
  def pull_on_hosts(hosts)
190
254
  on(hosts) do
191
- execute *DASH.auditor.record("Pulled image with version #{DASH.config.version}"), verbosity: :debug
192
- execute *DASH.builder.clean, raise_on_non_zero_exit: false
193
- execute *DASH.builder.pull
255
+ execute *DASH.auditor.record_then("Pulled image with version #{DASH.config.version}",
256
+ DASH.builder.clean_then_pull)
194
257
  execute *DASH.builder.validate_image
195
258
  end
196
259
  end
@@ -2,7 +2,7 @@
2
2
  # that are visible in deploy.yml alone, so they still run when every host is unreachable.
3
3
  class Dash::Cli::Doctor::ConfigChecks
4
4
  def run
5
- readiness_results
5
+ readiness_results + dockerfile_results
6
6
  end
7
7
 
8
8
  private
@@ -25,4 +25,39 @@ class Dash::Cli::Doctor::ConfigChecks
25
25
  "add a `healthcheck:` block, or opt out with `healthcheck: false`"
26
26
  end
27
27
  end
28
+
29
+ # The static half of the deploy report's advice: the same rules, without a build to
30
+ # measure against. `advice: false` is not consulted — it silences the block printed
31
+ # next to a deploy, and this check is one the operator asked for by name.
32
+ def dockerfile_results
33
+ dockerfile = DASH.config.builder.dockerfile
34
+ path = File.expand_path(dockerfile)
35
+
36
+ unless File.exist?(path)
37
+ return [ result(:dockerfile, dockerfile, :fail, "not found — `dash build push` fails with Missing #{dockerfile}") ]
38
+ end
39
+
40
+ findings = analyzer(dockerfile, path).findings
41
+ return [ result(:dockerfile, dockerfile, :ok, "no findings") ] if findings.empty?
42
+
43
+ findings.map { |finding| finding_result(finding) }
44
+ rescue StandardError => e
45
+ [ result(:dockerfile, dockerfile, :warn, "could not be analysed (#{e.class}: #{e.message})") ]
46
+ end
47
+
48
+ def analyzer(dockerfile, path)
49
+ Dash::Dockerfile::Analyzer.new \
50
+ document: Dash::Dockerfile::Parser.parse(File.read(path)),
51
+ path: dockerfile,
52
+ context_dir: File.expand_path(DASH.config.builder.context),
53
+ builder: DASH.config.builder,
54
+ ignore: DASH.config.report.ignore,
55
+ hadolint: DASH.config.report.hadolint?
56
+ end
57
+
58
+ # An informational finding is not a reason to hold up a deploy, so it reports as ok
59
+ # with its message intact — visible, but never the difference between ready and not.
60
+ def finding_result(finding)
61
+ result :dockerfile, finding.location, (finding.warn? ? :warn : :ok), "#{finding.message} [#{finding.rule}]"
62
+ end
28
63
  end
@@ -17,7 +17,8 @@ class Dash::Cli::Doctor
17
17
  ports: "Ports",
18
18
  dns: "DNS",
19
19
  certificate: "Certificates",
20
- readiness: "Readiness"
20
+ readiness: "Readiness",
21
+ dockerfile: "Dockerfile"
21
22
  }.freeze
22
23
 
23
24
  STATUS_COLORS = { ok: :green, warn: :yellow, fail: :red }.freeze
@@ -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
data/lib/dash/cli/main.rb CHANGED
@@ -26,16 +26,23 @@ class Dash::Cli::Main < Dash::Cli::Base
26
26
  print_config_banner
27
27
 
28
28
  say "Validate configuration and secrets...", :magenta
29
- DASH.config.validate_secrets!(include_accessories: boot_accessories)
29
+ timed("Validate config and secrets") { DASH.config.validate_secrets!(include_accessories: boot_accessories) }
30
30
 
31
31
  if options[:skip_push]
32
32
  say "Pull app image...", :magenta
33
33
  timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
34
34
  else
35
35
  say "Build and push app image...", :magenta
36
- timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
36
+ timed("Build and push app image") do |entry|
37
+ DASH.report.build_entry = entry
38
+ invoke "dash:cli:build:deliver", [], invoke_options
39
+ end
37
40
  end
38
41
 
42
+ # Before the boot, so the advice still prints when a boot fails — a slow build is
43
+ # exactly the kind of thing an operator wants to see on a deploy that went wrong.
44
+ analyze_report
45
+
39
46
  modify(lock: true) do
40
47
  run_hook "pre-deploy", secrets: true
41
48
 
@@ -59,7 +66,7 @@ class Dash::Cli::Main < Dash::Cli::Base
59
66
  end
60
67
  end
61
68
 
62
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
69
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details
63
70
  end
64
71
  end
65
72
 
@@ -74,16 +81,23 @@ class Dash::Cli::Main < Dash::Cli::Base
74
81
  print_config_banner
75
82
 
76
83
  say "Validate configuration and secrets...", :magenta
77
- DASH.config.validate_secrets!
84
+ timed("Validate config and secrets") { DASH.config.validate_secrets! }
78
85
 
79
86
  if options[:skip_push]
80
87
  say "Pull app image...", :magenta
81
88
  timed("Pull app image") { invoke "dash:cli:build:pull", [], invoke_options }
82
89
  else
83
90
  say "Build and push app image...", :magenta
84
- timed("Build and push app image") { invoke "dash:cli:build:deliver", [], invoke_options }
91
+ timed("Build and push app image") do |entry|
92
+ DASH.report.build_entry = entry
93
+ invoke "dash:cli:build:deliver", [], invoke_options
94
+ end
85
95
  end
86
96
 
97
+ # Before the boot, so the advice still prints when a boot fails — a slow build is
98
+ # exactly the kind of thing an operator wants to see on a deploy that went wrong.
99
+ analyze_report
100
+
87
101
  modify(lock: true) do
88
102
  run_hook "pre-deploy", secrets: true
89
103
 
@@ -99,7 +113,7 @@ class Dash::Cli::Main < Dash::Cli::Base
99
113
  end
100
114
  end
101
115
 
102
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s
116
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details
103
117
  end
104
118
  end
105
119
 
@@ -125,7 +139,7 @@ class Dash::Cli::Main < Dash::Cli::Base
125
139
  end
126
140
  end
127
141
 
128
- run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s if rolled_back
142
+ run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s, **report_hook_details if rolled_back
129
143
  end
130
144
  end
131
145
 
@@ -299,6 +313,9 @@ class Dash::Cli::Main < Dash::Cli::Base
299
313
  desc "prune", "Prune old application images and containers"
300
314
  subcommand "prune", Dash::Cli::Prune
301
315
 
316
+ desc "report", "Read the deploy reports saved under .dash/reports"
317
+ subcommand "report", Dash::Cli::Report
318
+
302
319
  desc "registry", "Login and -out of the image registry"
303
320
  subcommand "registry", Dash::Cli::Registry
304
321
 
@@ -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
@@ -11,9 +11,7 @@ class Dash::Cli::Prune < Dash::Cli::Base
11
11
  def images
12
12
  modify(lock: true, server_lock: true) do
13
13
  on(DASH.hosts) do
14
- execute *DASH.auditor.record("Pruned images"), verbosity: :debug
15
- execute *DASH.prune.dangling_images
16
- execute *DASH.prune.tagged_images
14
+ execute *DASH.auditor.record_then("Pruned images", DASH.prune.dangling_images, DASH.prune.tagged_images)
17
15
  end
18
16
  end
19
17
  end
@@ -26,11 +24,10 @@ class Dash::Cli::Prune < Dash::Cli::Base
26
24
 
27
25
  modify(lock: true, server_lock: true) do
28
26
  on(DASH.hosts) do |host|
29
- execute *DASH.auditor.record("Pruned containers"), verbosity: :debug
30
-
31
- DASH.roles_on(host).each do |role|
32
- execute *DASH.prune.app_containers(retain: retain, role: role)
33
- end
27
+ # One round trip per host, whatever it runs: a host with no app roles still
28
+ # records that the sweep reached it.
29
+ execute *DASH.auditor.record_then("Pruned containers",
30
+ *DASH.roles_on(host).map { |role| DASH.prune.app_containers(retain: retain, role: role) })
34
31
  end
35
32
  end
36
33
  end