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
@@ -0,0 +1,97 @@
1
+ require "json"
2
+
3
+ # Reads the JSON reports every deploy leaves under `.dash/reports`.
4
+ #
5
+ # Entirely local and read-only: no lock, no SSH, nothing that can change a server. It is
6
+ # the command to reach for after a deploy has finished and the table has scrolled away,
7
+ # and the one that answers "was it always this slow?".
8
+ class Dash::Cli::Report < Dash::Cli::Base
9
+ default_command :show
10
+
11
+ TREND_HEADINGS = %w[ started version total build boot advice ].freeze
12
+ COLUMNS = " %-20s %-10s %8s %8s %8s %s".freeze
13
+
14
+ desc "show", "Print the last saved deploy report"
15
+ option :last, type: :numeric, banner: "N", desc: "Print a trend table over the last N reports instead"
16
+ def show
17
+ if (last = options[:last])
18
+ return say "--last takes a positive number of reports, got #{last}", :red unless count?(last)
19
+
20
+ print_trend saved.recent(last.to_i)
21
+ else
22
+ print_latest saved.recent(1).first
23
+ end
24
+ end
25
+
26
+ desc "path", "Print the directory saved reports are written to"
27
+ def path
28
+ puts reports_directory
29
+ end
30
+
31
+ private
32
+ # Thor's :numeric happily hands over -1 or 2.5, which Array#first turns into a
33
+ # backtrace. A typo in a flag deserves a sentence, not a stack trace.
34
+ def count?(value)
35
+ value.to_i == value && value.to_i > 0
36
+ end
37
+
38
+ def saved
39
+ Dash::Report::History.new(reports_directory, destination: DASH.config.destination)
40
+ end
41
+
42
+ def print_latest(document)
43
+ return say_nothing_saved unless document
44
+
45
+ say "Deploy report for #{subject}", :magenta
46
+ puts summary_line(document)
47
+ puts Dash::Report.from_h(document).lines
48
+ end
49
+
50
+ def print_trend(documents)
51
+ return say_nothing_saved if documents.empty?
52
+
53
+ say "Last #{documents.size} #{"report".pluralize(documents.size)} for #{subject}", :magenta
54
+ puts format(COLUMNS, *TREND_HEADINGS)
55
+ documents.reverse_each { |document| puts trend_row(document) }
56
+ end
57
+
58
+ def subject
59
+ [ DASH.config.service, ("to #{DASH.config.destination}" if DASH.config.destination) ].compact.join(" ")
60
+ end
61
+
62
+ def summary_line(document)
63
+ " #{document[:command]} #{document[:status]} in #{seconds(document[:runtime])} at #{document[:started_at]}" \
64
+ "#{" (version #{document[:version]})" if document[:version]}#{error_note(document)}"
65
+ end
66
+
67
+ def error_note(document)
68
+ " — #{document.dig(:error, :class)}: #{document.dig(:error, :message)}" if document[:error]
69
+ end
70
+
71
+ def trend_row(document)
72
+ format COLUMNS, document[:started_at], (document[:version] || "").to_s[0...10],
73
+ seconds(document[:runtime]), phase(document, Dash::Report::Trends::BUILD_PHASE),
74
+ phase(document, Dash::Report::Trends::BOOT_PHASE), advice_count(document)
75
+ end
76
+
77
+ def phase(document, name)
78
+ found = Array(document[:phases]).find { |candidate| candidate[:name] == name && candidate[:depth].to_i.zero? }
79
+
80
+ found ? seconds(found[:seconds]) : "-"
81
+ end
82
+
83
+ def advice_count(document)
84
+ findings = Array(document[:advice])
85
+ warnings = findings.count { |finding| finding[:severity] == "warn" }
86
+
87
+ "#{findings.size}#{" (#{warnings} warn)" if warnings > 0}"
88
+ end
89
+
90
+ def seconds(value)
91
+ format("%.1fs", value.to_f)
92
+ end
93
+
94
+ def say_nothing_saved
95
+ say "No saved reports for #{subject} in #{reports_directory}", :yellow
96
+ end
97
+ end
@@ -10,5 +10,10 @@
10
10
  # DASH_ROLES (if set)
11
11
  # DASH_DESTINATION (if set)
12
12
  # DASH_RUNTIME
13
+ # DASH_BUILD_RUNTIME (if the deploy built an image)
14
+ # DASH_BOOT_RUNTIME (if the deploy booted containers)
15
+ # DASH_ADVICE_COUNT
16
+ # DASH_ADVICE_WARNINGS
17
+ # DASH_REPORT_PATH (unless report/history is 0)
13
18
 
14
19
  echo "$DASH_PERFORMER deployed $DASH_VERSION to $DASH_DESTINATION in $DASH_RUNTIME seconds"
@@ -6,7 +6,13 @@ require "active_support/notifications"
6
6
 
7
7
  class Dash::Commander
8
8
  attr_accessor :verbosity, :holding_lock, :holding_server_lock, :connected, :logging, :lock_wait, :lock_wait_timeout, :lock_wait_interval
9
- attr_reader :specific_roles, :specific_hosts, :timings
9
+ attr_reader :specific_roles, :specific_hosts, :timings, :report
10
+
11
+ # Hosts whose run directory this process has already swept, so the second lock acquire
12
+ # of a command does not re-run the migration everywhere. Per host rather than a flag:
13
+ # `dash upgrade` narrows the host set between acquires, and a host that was never in
14
+ # scope has never been swept.
15
+ attr_reader :run_directory_ensured_on
10
16
  delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics
11
17
 
12
18
  def initialize
@@ -24,10 +30,12 @@ class Dash::Commander
24
30
  self.lock_wait_interval = 15
25
31
  @modify_depth = 0
26
32
  @timings = Dash::Timings.new
33
+ @report = Dash::Report.new(timings: @timings)
27
34
  @specifics = @specific_roles = @specific_hosts = nil
28
35
  @config = @config_kwargs = nil
29
36
  @output_logger = nil
30
37
  @commands = {}
38
+ @run_directory_ensured_on = []
31
39
  end
32
40
 
33
41
  def config
@@ -170,7 +178,7 @@ class Dash::Commander
170
178
  @logging = true
171
179
  if modify_started
172
180
  ActiveSupport::Notifications.instrument("modify.kamal",
173
- command: command, subcommand: subcommand, destination: config.destination, hosts: hosts) { yield }
181
+ command: command, subcommand: subcommand, destination: config.destination, hosts: hosts, report: report) { yield }
174
182
  else
175
183
  yield
176
184
  end
@@ -3,6 +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 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
+ BOOT_STATE_SEPARATOR = "--%--"
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
+
6
16
  attr_reader :role, :host
7
17
 
8
18
  delegate :container_name, to: :role
@@ -50,6 +60,31 @@ class Dash::Commands::App < Dash::Commands::Base
50
60
  docker :exec, container_name(version), *shell([ role.healthcheck.exec ])
51
61
  end
52
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
+
53
88
  def stop(version: nil)
54
89
  pipe \
55
90
  version ? container_id_for_version(version) : current_running_container_id,
@@ -75,6 +110,30 @@ class Dash::Commands::App < Dash::Commands::Base
75
110
  extract_version_from_name
76
111
  end
77
112
 
113
+ # Everything a boot needs to know about a host before it starts anything: whether a
114
+ # container for the version being deployed already exists (so it can be renamed out of
115
+ # the way) and which version is running now (so it can be stopped once the new one is
116
+ # live). Two questions, one round trip, answers split on BOOT_STATE_SEPARATOR.
117
+ #
118
+ # Chained with `;` rather than `&&`: an empty answer to either is a normal result, not
119
+ # a failure, and the second question must be asked whatever the first one said.
120
+ def boot_state(version)
121
+ chain \
122
+ container_id_for_version(version),
123
+ [ :echo, BOOT_STATE_SEPARATOR ],
124
+ current_running_version
125
+ end
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
+
78
137
  def list_versions(*docker_args, statuses: nil)
79
138
  pipe \
80
139
  docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
@@ -86,6 +145,28 @@ class Dash::Commands::App < Dash::Commands::Base
86
145
  end
87
146
 
88
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
+
89
170
  def latest_image_id
90
171
  docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'"
91
172
  end
@@ -14,6 +14,16 @@ class Dash::Commands::Auditor < Dash::Commands::Base
14
14
  append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file)
15
15
  end
16
16
 
17
+ # The audit line and the action it describes in one round trip, still in that order:
18
+ # the log is written first, and `&&` means a failed write aborts the action exactly as
19
+ # a failed standalone audit would have.
20
+ #
21
+ # Only ever fold in commands the caller would `execute`. A `capture` folded in here
22
+ # would come back with nothing to distinguish the audit's own output from the answer.
23
+ def record_then(line, *commands, **details)
24
+ combine record(line, **details), *commands
25
+ end
26
+
17
27
  def reveal
18
28
  [ :tail, "-n", 50, audit_log_file ]
19
29
  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
@@ -14,9 +14,27 @@ class Dash::Commands::Builder::Base < Dash::Commands::Base
14
14
  docker :image, :rm, "--force", config.absolute_image
15
15
  end
16
16
 
17
+ # Dropping the old image is housekeeping - a host that never had it is not an error -
18
+ # so it must not short-circuit whatever it shares a round trip with.
19
+ #
20
+ # The `|| true` is parenthesised because `&&` and `||` bind equally and associate left:
21
+ # ungrouped, an `audit && clean || true && pull` chain lets a FAILED audit fall into the
22
+ # same `|| true` and pull anyway, exit status 0. The group confines it to the clean.
23
+ #
24
+ # Composed only, never executed on its own: SSHKit's command map prefixes an unknown
25
+ # first word with /usr/bin/env, and the first word here is `(`.
26
+ def clean_then_pull
27
+ combine [ "(", *any(clean, [ :true ]), ")" ], pull
28
+ end
29
+
17
30
  def push(export_action = "registry", tag_as_dirty: false, no_cache: false)
18
31
  docker :buildx, :build,
19
32
  "--output=type=#{export_action}",
33
+ # Plain progress is what dash parses into the build rows of the deploy report
34
+ # (Dash::Build::ProgressParser). buildx already falls back to it when stdout is a
35
+ # pipe, which it always is under SSHKit — this only pins it so the format cannot
36
+ # change under us.
37
+ "--progress=plain",
20
38
  *platform_options(arches),
21
39
  *([ "--builder", builder_name ] unless docker_driver?),
22
40
  *build_tag_options(tag_as_dirty: tag_as_dirty),
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
2
2
 
3
3
  class Dash::Commands::Builder < Dash::Commands::Base
4
4
  delegate \
5
- :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder,
5
+ :create, :remove, :dev, :push, :clean, :pull, :clean_then_pull, :info, :inspect_builder,
6
6
  :validate_image, :first_mirror, :login_to_registry_locally?, :push_env,
7
7
  to: :target
8
8
 
@@ -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
 
@@ -229,6 +229,12 @@ logging:
229
229
  output:
230
230
  ...
231
231
 
232
+ # Deploy report
233
+ #
234
+ # Advice printed under the deploy timing table, see dash docs report:
235
+ report:
236
+ ...
237
+
232
238
  # Aliases
233
239
  #
234
240
  # Alias configuration, see dash docs alias: