dash 4.0.8 → 4.1.0

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 (60) 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 +20 -10
  6. data/lib/dash/cli/app.rb +2 -2
  7. data/lib/dash/cli/base.rb +117 -2
  8. data/lib/dash/cli/build.rb +61 -5
  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/main.rb +24 -7
  12. data/lib/dash/cli/prune.rb +5 -8
  13. data/lib/dash/cli/report.rb +97 -0
  14. data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
  15. data/lib/dash/commander.rb +10 -2
  16. data/lib/dash/commands/app.rb +18 -0
  17. data/lib/dash/commands/auditor.rb +10 -0
  18. data/lib/dash/commands/builder/base.rb +18 -0
  19. data/lib/dash/commands/builder.rb +1 -1
  20. data/lib/dash/configuration/docs/configuration.yml +6 -0
  21. data/lib/dash/configuration/docs/report.yml +39 -0
  22. data/lib/dash/configuration/report.rb +65 -0
  23. data/lib/dash/configuration.rb +2 -1
  24. data/lib/dash/dockerfile/analyzer.rb +66 -0
  25. data/lib/dash/dockerfile/context.rb +147 -0
  26. data/lib/dash/dockerfile/dockerignore.rb +29 -0
  27. data/lib/dash/dockerfile/document.rb +28 -0
  28. data/lib/dash/dockerfile/finding.rb +20 -0
  29. data/lib/dash/dockerfile/hadolint.rb +75 -0
  30. data/lib/dash/dockerfile/instruction.rb +58 -0
  31. data/lib/dash/dockerfile/parser.rb +199 -0
  32. data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
  33. data/lib/dash/dockerfile/rules/base.rb +44 -0
  34. data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
  35. data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
  36. data/lib/dash/dockerfile/rules/context_size.rb +20 -0
  37. data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
  38. data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
  39. data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
  40. data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
  41. data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
  42. data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
  43. data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
  44. data/lib/dash/dockerfile/rules/root_user.rb +12 -0
  45. data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
  46. data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
  47. data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
  48. data/lib/dash/dockerfile/stage.rb +65 -0
  49. data/lib/dash/otel_shipper.rb +5 -4
  50. data/lib/dash/output/otel_logger.rb +52 -0
  51. data/lib/dash/report/history.rb +94 -0
  52. data/lib/dash/report/trends.rb +129 -0
  53. data/lib/dash/report/writer.rb +142 -0
  54. data/lib/dash/report.rb +170 -0
  55. data/lib/dash/sshkit_with_ext.rb +62 -0
  56. data/lib/dash/timings.rb +163 -10
  57. data/lib/dash/utils.rb +7 -0
  58. data/lib/dash/version.rb +1 -1
  59. data/lib/dash.rb +4 -0
  60. metadata +36 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a417156134dd79a4724f87192f70baa55fdbc4478a115e021845e3742bacee88
4
- data.tar.gz: ebdbb8d7f192b692c31c8cdaa9834584c01dec3653b744b1a481672fee179d28
3
+ metadata.gz: 32799cf5de0b149a2b36f88e0af2d671c82a5d05a3c54180f034d8574eb58bf8
4
+ data.tar.gz: 1e33d017d9ec6e3e3545ea0c1125d9d4b38a2e12557197b2c827e00e608b4aa0
5
5
  SHA512:
6
- metadata.gz: 3b263730b7ccf4eeeb857304477945fd9d2476bdf52cdb30a964b12b09143b36b5f585ae672d776eac29a6e009606add151aa6e9dd98385f9146980bbc40a9db
7
- data.tar.gz: a3ebb3e0141a0f692bd70205aa3d438f9a3cbd1022d1b92c229bf1daf19294c4826a7420c0fefbb077f5a6d6cb46016ee6ab3926bb08f11383bb7a6eb0aaa23e
6
+ metadata.gz: bac661834cb626277175f2e0a9abbfb273975a0e70dd45779352798f547c5097ff836cfaed017316a207d59aa4d62b1b337112692db9fa7870aec2056bcd14e4
7
+ data.tar.gz: a8879e8f7b3250903eb854f23436b63892cdced0a303969039ebc809f29cce5231f3dda51faa2c5d3755b55edb8b543a007f96964272dc7afe1a1cf6c132ec6b
@@ -0,0 +1,136 @@
1
+ # Reads `docker buildx build --progress=plain` as it streams past and turns it into a
2
+ # per-step report. It is an SSHKit interaction handler, so it sees the same bytes SSHKit
3
+ # is already printing — no second process, no `docker buildx history`, no extra command.
4
+ #
5
+ # The stream is line-oriented but arrives in chunks (the SSH backend splits on packet
6
+ # boundaries, not newlines), so data is buffered and only whole lines are parsed. Every
7
+ # line belongs to a vertex — `#12` — and the first line for a vertex names it; the rest
8
+ # are events on it (DONE, CACHED, ERROR) or its own stdout, which is ignored.
9
+ #
10
+ # Nothing here may raise into a build. A parse error stops the parsing and is reported
11
+ # once by the caller; the deploy keeps whatever was collected up to that point.
12
+ class Dash::Build::ProgressParser
13
+ VERTEX = /\A#(?<number>\d+)(?: (?<rest>.*))?\z/
14
+
15
+ # `[linux/amd64 build 2/3] RUN …` — the platform prefix only appears on multi-platform
16
+ # builds, and the stage name only when the Dockerfile named it with AS.
17
+ STEP = %r{\A\[(?:(?<platform>[a-z0-9]+/[a-z0-9][\w./-]*) )?(?:(?<stage>[A-Za-z0-9][\w.-]*) )?(?<ordinal>\d+)/(?<steps_in_stage>\d+)\] (?<instruction>.+)\z}
18
+ INTERNAL = /\A\[(?:\S+ )?internal\] (?<what>.+)\z/
19
+ DONE = /\ADONE (?<seconds>\d+(?:\.\d+)?)s\z/
20
+ ERROR = /\AERROR: (?<message>.+)\z/
21
+ TRANSFERRING_CONTEXT = /\Atransferring context: (?<size>[\d.]+)(?<unit>[a-zA-Z]+)(?: (?<seconds>\d+(?:\.\d+)?)s)? done\z/
22
+ PUSHING = /\Apushing .*?(?<seconds>\d+(?:\.\d+)?)s done\z/
23
+
24
+ BYTE_UNITS = { "b" => 1, "kb" => 1_000, "mb" => 1_000_000, "gb" => 1_000_000_000, "tb" => 1_000_000_000_000 }.freeze
25
+
26
+ attr_reader :error
27
+
28
+ def initialize
29
+ @steps = {}
30
+ @order = []
31
+ @buffer = +""
32
+ @push_seconds = 0.0
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ # SSHKit's interaction-handler contract.
37
+ def on_data(_command, _stream_name, data, _channel)
38
+ @mutex.synchronize do
39
+ next if @error
40
+
41
+ @buffer << data.to_s
42
+ while (newline = @buffer.index("\n"))
43
+ parse_line @buffer.slice!(0..newline).chomp
44
+ end
45
+ rescue StandardError => e
46
+ @error = e
47
+ end
48
+ end
49
+
50
+ # The last line of a build has no trailing newline when the command dies mid-write.
51
+ def finish
52
+ @mutex.synchronize do
53
+ next if @error
54
+
55
+ parse_line @buffer.chomp unless @buffer.empty?
56
+ @buffer = +""
57
+ rescue StandardError => e
58
+ @error = e
59
+ end
60
+ end
61
+
62
+ def result
63
+ @mutex.synchronize do
64
+ Dash::Build::Report.new(steps: @order.map { |number| @steps[number] }, push_seconds: @push_seconds)
65
+ end
66
+ end
67
+
68
+ private
69
+ def parse_line(line)
70
+ match = VERTEX.match(line) or return
71
+ rest = match[:rest].to_s.strip
72
+
73
+ # `#3 ...` is buildx saying the vertex is deferred, not a step of its own.
74
+ return if rest.empty? || rest == "..."
75
+
76
+ number = match[:number].to_i
77
+ step = @steps[number]
78
+
79
+ unless step
80
+ step = @steps[number] = build_step(number, rest)
81
+ @order << number
82
+ end
83
+
84
+ update step, rest
85
+ end
86
+
87
+ def build_step(number, rest)
88
+ Dash::Build::Step.new(number, kind: :other, name: rest).tap do |step|
89
+ if (match = STEP.match(rest))
90
+ step.platform = match[:platform]
91
+ step.stage = match[:stage]
92
+ step.ordinal = match[:ordinal].to_i
93
+ step.steps_in_stage = match[:steps_in_stage].to_i
94
+ step.instruction = match[:instruction].squeeze(" ").strip
95
+ step.kind = step.instruction.start_with?("FROM ") ? :from : :instruction
96
+ elsif (match = INTERNAL.match(rest))
97
+ step.kind = internal_kind(match[:what])
98
+ elsif rest.start_with?("exporting cache")
99
+ step.kind = :cache_export
100
+ elsif rest.match?(/\A(exporting|pushing|writing image)/)
101
+ step.kind = :export
102
+ end
103
+ end
104
+ end
105
+
106
+ def internal_kind(what)
107
+ case what
108
+ when /\Aload build context/ then :context
109
+ when /\Aload metadata for/ then :metadata
110
+ else :other
111
+ end
112
+ end
113
+
114
+ # Later lines for a vertex are its result, its output, or its progress. buildx
115
+ # re-reports DONE per platform on a multi-platform build, so the last one wins.
116
+ def update(step, rest)
117
+ if (match = DONE.match(rest))
118
+ step.seconds = match[:seconds].to_f
119
+ elsif rest == "CACHED"
120
+ step.cached = true
121
+ step.seconds = 0.0
122
+ elsif (match = ERROR.match(rest))
123
+ step.error = match[:message]
124
+ elsif (match = TRANSFERRING_CONTEXT.match(rest)) && step.kind == :context
125
+ step.bytes = bytes(match[:size], match[:unit])
126
+ step.seconds ||= match[:seconds]&.to_f
127
+ elsif (match = PUSHING.match(rest)) && step.kind == :export
128
+ @push_seconds += match[:seconds].to_f
129
+ end
130
+ end
131
+
132
+ # buildx reports decimal units — 25.18MB is 25,180,000 bytes, not 25.18 MiB.
133
+ def bytes(size, unit)
134
+ (size.to_f * BYTE_UNITS.fetch(unit.downcase, 1)).round
135
+ end
136
+ end
@@ -0,0 +1,104 @@
1
+ # What the build actually spent its time on, derived from the buildx progress stream.
2
+ #
3
+ # "Steps" here means the operator's own Dockerfile steps — the vertices buildx numbered
4
+ # `[stage k/m]`. BuildKit's own bookkeeping (booting, auth tokens, metadata lookups) is
5
+ # kept in `steps` because it is still time the build took, but it is never counted as a
6
+ # step the operator wrote.
7
+ class Dash::Build::Report
8
+ attr_reader :steps, :push_seconds
9
+
10
+ # Rebuilds a report from what #to_h exported. Only the steps and the push are restored:
11
+ # every other number in the export is derived from them, so recomputing keeps a
12
+ # hand-edited file from claiming a total its own steps do not add up to.
13
+ def self.from_h(hash)
14
+ hash = hash.transform_keys(&:to_sym)
15
+
16
+ new steps: Array(hash[:steps]).map { |step| Dash::Build::Step.from_h(step) }, push_seconds: hash[:push_seconds].to_f
17
+ end
18
+
19
+ def initialize(steps: [], push_seconds: 0.0)
20
+ @steps = steps
21
+ @push_seconds = push_seconds
22
+ end
23
+
24
+ def any?
25
+ steps.any?
26
+ end
27
+
28
+ def dockerfile_steps
29
+ steps.select(&:dockerfile_step?)
30
+ end
31
+
32
+ def instruction_steps
33
+ steps.select { |step| step.kind == :instruction }
34
+ end
35
+
36
+ def cached_steps
37
+ dockerfile_steps.select(&:cached)
38
+ end
39
+
40
+ def uncached_steps
41
+ dockerfile_steps.reject(&:cached)
42
+ end
43
+
44
+ # The rows worth printing: the operator's own instructions, longest first. A FROM or a
45
+ # metadata lookup is not something they can speed up by editing the Dockerfile.
46
+ def slowest(count)
47
+ instruction_steps.reject(&:cached).sort_by { |step| -step.seconds.to_f }.first(count)
48
+ end
49
+
50
+ def context_step
51
+ steps.find { |step| step.kind == :context }
52
+ end
53
+
54
+ def context_bytes
55
+ context_step&.bytes
56
+ end
57
+
58
+ def context_seconds
59
+ context_step&.seconds
60
+ end
61
+
62
+ def export_seconds
63
+ seconds_for(:export)
64
+ end
65
+
66
+ def cache_export_seconds
67
+ seconds_for(:cache_export)
68
+ end
69
+
70
+ def total_step_seconds
71
+ steps.sum { |step| step.seconds.to_f }
72
+ end
73
+
74
+ def errors
75
+ steps.select(&:error)
76
+ end
77
+
78
+ # The errors worth putting in front of an operator. BuildKit reports a cache-import
79
+ # miss as an ERROR on its own vertex — the first build against a fresh cache always
80
+ # has one — and a row saying "error" for something that did not fail the build teaches
81
+ # people to ignore the column.
82
+ def failed_steps
83
+ errors.select { |step| step.dockerfile_step? || step.kind == :export }
84
+ end
85
+
86
+ def to_h
87
+ {
88
+ context_bytes: context_bytes,
89
+ context_seconds: context_seconds,
90
+ cached_steps: cached_steps.size,
91
+ total_steps: dockerfile_steps.size,
92
+ export_seconds: export_seconds,
93
+ cache_export_seconds: cache_export_seconds,
94
+ push_seconds: push_seconds,
95
+ total_step_seconds: total_step_seconds,
96
+ steps: steps.map(&:to_h)
97
+ }
98
+ end
99
+
100
+ private
101
+ def seconds_for(kind)
102
+ steps.sum { |step| step.kind == kind ? step.seconds.to_f : 0.0 }
103
+ end
104
+ end
@@ -0,0 +1,49 @@
1
+ # One buildx vertex. A vertex is a unit of work BuildKit reports on: a Dockerfile
2
+ # instruction, the context transfer, a metadata lookup, the export to the registry.
3
+ #
4
+ # Only the ones with an ordinal (`[build 5/9]`) are the operator's own steps — the rest
5
+ # are BuildKit's own bookkeeping, and counting them would make "cached steps 9 of 22"
6
+ # say something nobody asked.
7
+ class Dash::Build::Step
8
+ attr_reader :number
9
+ attr_accessor :kind, :name, :platform, :stage, :ordinal, :steps_in_stage, :instruction, :seconds, :cached, :error, :bytes
10
+
11
+ def self.from_h(step)
12
+ step = step.transform_keys(&:to_sym)
13
+
14
+ new(step[:number], kind: step[:kind]&.to_sym || :other, name: step[:label]).tap do |rebuilt|
15
+ rebuilt.platform, rebuilt.stage = step[:platform], step[:stage]
16
+ rebuilt.ordinal, rebuilt.steps_in_stage = step[:ordinal], step[:steps_in_stage]
17
+ rebuilt.instruction, rebuilt.seconds = step[:instruction], step[:seconds]
18
+ rebuilt.cached, rebuilt.bytes, rebuilt.error = !!step[:cached], step[:bytes], step[:error]
19
+ end
20
+ end
21
+
22
+ def initialize(number, kind: :other, name: nil)
23
+ @number = number
24
+ @kind = kind
25
+ @name = name
26
+ @cached = false
27
+ end
28
+
29
+ # What buildx itself printed for this vertex, minus the platform prefix it adds on a
30
+ # multi-platform build — the operator matches these against their Dockerfile, and the
31
+ # platform is already its own column in the data.
32
+ def label
33
+ ordinal ? "[#{[ stage, "#{ordinal}/#{steps_in_stage}" ].compact.join(" ")}] #{instruction}" : name.to_s
34
+ end
35
+
36
+ def dockerfile_step?
37
+ !ordinal.nil?
38
+ end
39
+
40
+ # `label` is here for whatever reads the JSON — it is the string buildx printed and the
41
+ # one a human matches against their Dockerfile — and the parts it is built from are here
42
+ # so #from_h can rebuild it rather than trusting a field a hand-edited file may disagree
43
+ # with.
44
+ def to_h
45
+ { number: number, kind: kind, label: label, platform: platform, stage: stage, ordinal: ordinal,
46
+ steps_in_stage: steps_in_stage, instruction: instruction, seconds: seconds, cached: cached,
47
+ bytes: bytes, error: error }
48
+ end
49
+ end
@@ -38,22 +38,36 @@ class Dash::Cli::App::Boot
38
38
  end
39
39
 
40
40
  private
41
+ # Both answers come back from one round trip, which means the running version is read
42
+ # before any rename happens. When the clashing container IS the running one, the
43
+ # version to stop later is the name it was renamed to - the name that was read now
44
+ # belongs to the container this boot is about to start.
41
45
  def old_version_renamed_if_clashing
42
- if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
46
+ clashing_container_id, old_version = capture_boot_state
47
+
48
+ if clashing_container_id.present?
43
49
  renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
44
50
  info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
45
- audit("Renaming container #{version} to #{renamed_version}")
46
- execute *app.rename_container(version: version, new_version: renamed_version)
51
+ execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
52
+ app.rename_container(version: version, new_version: renamed_version))
53
+
54
+ old_version = renamed_version if old_version == version
47
55
  end
48
56
 
49
- capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
57
+ old_version
58
+ end
59
+
60
+ 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)}$/)
63
+
64
+ [ clashing.strip.presence, running.strip.presence ]
50
65
  end
51
66
 
52
67
  def start_new_version
53
- audit "Booted app version #{version}"
54
68
  hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
55
69
 
56
- execute *app.ensure_env_directory
70
+ execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
57
71
  upload! role.secrets_io(host), role.secrets_path, mode: "0600"
58
72
 
59
73
  execute *app.run(hostname: hostname)
@@ -161,10 +175,6 @@ class Dash::Cli::App::Boot
161
175
  @auditor = DASH.auditor(role: role)
162
176
  end
163
177
 
164
- def audit(message)
165
- execute *auditor.record(message), verbosity: :debug
166
- end
167
-
168
178
  def gatekeeper?
169
179
  barrier && barrier_role?
170
180
  end
data/lib/dash/cli/app.rb CHANGED
@@ -40,8 +40,8 @@ class Dash::Cli::App < Dash::Cli::Base
40
40
 
41
41
  # Tag once the app booted on all hosts
42
42
  on(DASH.app_hosts) do |host|
43
- execute *DASH.auditor.record("Tagging #{DASH.config.absolute_image} as the latest image"), verbosity: :debug
44
- execute *DASH.app.tag_latest_image
43
+ execute *DASH.auditor.record_then("Tagging #{DASH.config.absolute_image} as the latest image",
44
+ DASH.app.tag_latest_image)
45
45
  end
46
46
  end
47
47
  end
data/lib/dash/cli/base.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "thor"
2
+ require "time"
2
3
  require "dash/sshkit_with_ext"
3
4
 
4
5
  module Dash::Cli
@@ -145,13 +146,111 @@ module Dash::Cli
145
146
  def print_runtime
146
147
  started_at = Time.now
147
148
  @print_runtime_depth = @print_runtime_depth.to_i + 1
149
+ record_startup_timing if @print_runtime_depth == 1
148
150
  yield
149
151
  Time.now - started_at
152
+ rescue StandardError => e
153
+ # Kept so the saved report says how the run ended. `setup` nests, and the inner
154
+ # deploy sets it first — the outermost writer reads the same error either way.
155
+ @report_error = e
156
+ raise
150
157
  ensure
151
158
  @print_runtime_depth -= 1
152
159
  runtime = Time.now - started_at
153
160
  puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
154
- puts DASH.timings.lines if @print_runtime_depth.zero? && DASH.timings.any?
161
+ finish_report(started_at, runtime) if @print_runtime_depth.zero? && DASH.timings.any?
162
+ end
163
+
164
+ # Trends, then the table, then the saved JSON. Each half is guarded on its own so a
165
+ # report that cannot be written still prints, and a table that cannot be compared is
166
+ # still a table — none of this is allowed to be why a deploy ends badly.
167
+ def finish_report(started_at, runtime)
168
+ run = nil
169
+
170
+ guarded_report do
171
+ run = report_run(started_at, runtime)
172
+ DASH.report.advice += report_trends(run)
173
+ end
174
+
175
+ puts DASH.report.lines
176
+
177
+ guarded_report { write_report(run) } if run
178
+ end
179
+
180
+ # What this invocation was, for the saved report and for the trend rules that
181
+ # compare it with the invocations before it.
182
+ def report_run(started_at, runtime)
183
+ {
184
+ command: [ command, subcommand ].compact.join(" "),
185
+ service: DASH.config.service, destination: DASH.config.destination, version: DASH.config.version,
186
+ started_at: started_at.getutc.iso8601, runtime: runtime.round(1),
187
+ status: @report_error ? "failed" : "succeeded",
188
+ error: @report_error && { class: @report_error.class.name, message: @report_error.message }
189
+ }.compact
190
+ end
191
+
192
+ def report_trends(run)
193
+ return [] unless DASH.config.report.advice?
194
+
195
+ history = Dash::Report::History.new(reports_directory, destination: DASH.config.destination)
196
+
197
+ Dash::Report::Trends.new(run.merge(phases: DASH.timings.to_h),
198
+ history: history.recent(DASH.config.report.history), ignore: DASH.config.report.ignore).findings
199
+ end
200
+
201
+ def write_report(run)
202
+ @report_path = Dash::Report::Writer.new(DASH.report,
203
+ run: run, keep: DASH.config.report.history, directory: reports_directory).write
204
+
205
+ puts " Report written to #{@report_path}" if @report_path
206
+ end
207
+
208
+ def reports_directory
209
+ Dash::ProjectDirectory.join("reports")
210
+ end
211
+
212
+ # Summary numbers for the post-deploy hook, so a hook can page on a build that
213
+ # doubled or a warning that appeared without re-deriving any of it. Phases that did
214
+ # not run contribute nothing rather than a zero that reads as "instant".
215
+ def report_hook_details
216
+ guarded_report({}) do
217
+ {
218
+ build_runtime: phase_runtime(Dash::Report::Trends::BUILD_PHASE),
219
+ boot_runtime: phase_runtime(Dash::Report::Trends::BOOT_PHASE),
220
+ advice_count: DASH.report.advice.size.to_s,
221
+ advice_warnings: DASH.report.advice.count(&:warn?).to_s,
222
+ report_path: @report_path
223
+ }.compact
224
+ end
225
+ end
226
+
227
+ def phase_runtime(name)
228
+ DASH.timings.seconds_for(name)&.round(1)&.to_s
229
+ end
230
+
231
+ # Everything that happened before the first phase could be timed: requiring the gem,
232
+ # Zeitwerk, Thor parsing the command line, and building the config. It is the one row
233
+ # an operator cannot influence from deploy.yml, which is exactly why it has to be
234
+ # visible — a five-second startup is dash's problem, not theirs.
235
+ def record_startup_timing
236
+ DASH.timings.record "Startup (load, config)", Process.clock_gettime(Process::CLOCK_MONOTONIC) - Dash::PROCESS_STARTED_AT
237
+ end
238
+
239
+ # Advice about the Dockerfile, run once the build report is in so measured rules can
240
+ # name real seconds. Nothing here is allowed to end a deploy: a report is a courtesy
241
+ # printed next to the work, and the work already succeeded or failed on its own.
242
+ def analyze_report
243
+ guarded_report { DASH.report.analyze!(DASH.config) }
244
+ end
245
+
246
+ # `fallback` is what the caller gets when the report could not be produced, for the
247
+ # callers that need a value rather than a side effect.
248
+ def guarded_report(fallback = nil)
249
+ yield
250
+ rescue StandardError => e
251
+ say "Deploy report unavailable: #{e.class}: #{e.message}", :yellow
252
+ say e.backtrace.join("\n"), :yellow if ENV["VERBOSE"]
253
+ fallback
155
254
  end
156
255
 
157
256
  def timed(name, depth: 0, &block)
@@ -204,6 +303,10 @@ module Dash::Cli
204
303
  # in one `on(hosts)` sweep would leave the locks we did win in place, and
205
304
  # every retry would then collide with itself and wait out the timeout.
206
305
  def acquire_server_lock
306
+ timed("Acquire server lock") { acquire_server_lock_now }
307
+ end
308
+
309
+ def acquire_server_lock_now
207
310
  ensure_run_directory
208
311
 
209
312
  timeout = DASH.lock_wait_timeout
@@ -343,6 +446,10 @@ module Dash::Cli
343
446
  end
344
447
 
345
448
  def acquire_lock
449
+ timed("Acquire deploy lock") { acquire_lock_now }
450
+ end
451
+
452
+ def acquire_lock_now
346
453
  ensure_run_directory
347
454
 
348
455
  if DASH.lock_wait
@@ -470,10 +577,18 @@ module Dash::Cli
470
577
  instance_variable_get("@_invocations")[cli_class].pop
471
578
  end
472
579
 
580
+ # Every lock acquire wants the run directory to exist, but the sweep is idempotent
581
+ # and a process only needs it once per host - the deploy lock and the server lock
582
+ # were paying for it twice.
473
583
  def ensure_run_directory
474
- on(DASH.hosts) do
584
+ pending = DASH.hosts.map(&:to_s) - DASH.run_directory_ensured_on
585
+ return if pending.empty?
586
+
587
+ on(pending) do
475
588
  execute(*DASH.server.ensure_run_directory)
476
589
  end
590
+
591
+ DASH.run_directory_ensured_on.concat(pending)
477
592
  end
478
593
 
479
594
  def with_env(env)
@@ -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,11 +63,13 @@ 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"
@@ -147,17 +152,66 @@ class Dash::Cli::Build < Dash::Cli::Base
147
152
  say
148
153
  end
149
154
 
155
+ parser = build_progress_parser
156
+ handler = handler_for(parser)
157
+
150
158
  with_env(DASH.config.builder.secrets) do
151
159
  run_locally do
152
160
  build = DASH.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache])
153
161
  DASH.with_verbosity(:debug) do
154
- execute(*build)
162
+ execute(*build, **handler)
155
163
  end
156
164
  end
157
165
  end
166
+ ensure
167
+ # `dev` builds the working directory even when `push` would clone, so the advice reads
168
+ # the Dockerfile here rather than in a clone that may not exist.
169
+ record_build_report parser, build_directory: "."
158
170
  end
159
171
 
160
172
  private
173
+ # Buildpacks print a total and nothing else, so there is nothing for the parser to
174
+ # read and no reason to attach it.
175
+ def build_progress_parser
176
+ Dash::Build::ProgressParser.new unless DASH.builder.pack?
177
+ end
178
+
179
+ def handler_for(parser)
180
+ parser ? { interaction_handler: parser } : {}
181
+ end
182
+
183
+ # Runs whether the build succeeded or failed: a partial report naming the step that
184
+ # broke is exactly what an operator wants from a failed build. Measurement must never
185
+ # be the reason a build fails, so nothing in here is allowed to raise.
186
+ def record_build_report(parser, build_directory: DASH.config.builder.build_directory)
187
+ return unless parser
188
+
189
+ guarded_report do
190
+ parser.finish
191
+ DASH.report.build = parser.result
192
+
193
+ say "Deploy report unavailable: #{parser.error.class}: #{parser.error.message}", :yellow if parser.error
194
+
195
+ # Inside a deploy the phase table prints these at the end and the deploy runs its
196
+ # own analysis once the image is delivered. Standalone, this is the only chance.
197
+ unless DASH.report.build_entry
198
+ print_build_report
199
+ DASH.report.analyze!(DASH.config, build_directory: build_directory)
200
+ puts DASH.report.advice_lines
201
+ end
202
+ end
203
+ end
204
+
205
+ # A standalone `dash build push` has no phase table to sit under, but a CI pipeline
206
+ # that splits build from deploy should still see where the build time went.
207
+ def print_build_report
208
+ rows = DASH.report.build_lines
209
+ return if rows.empty?
210
+
211
+ puts " Build"
212
+ puts rows
213
+ end
214
+
161
215
  def connect_to_remote_host(remote_host)
162
216
  remote_uri = URI.parse(remote_host)
163
217
  if remote_uri.scheme == "ssh"
@@ -186,11 +240,13 @@ class Dash::Cli::Build < Dash::Cli::Base
186
240
  end
187
241
  end
188
242
 
243
+ # Audit, clean and pull share one round trip. validate_image keeps its own: folding it
244
+ # in would put the pull under validate_image's trailing `|| (echo ... && exit 1)`, and
245
+ # a failed pull would then report a missing service label.
189
246
  def pull_on_hosts(hosts)
190
247
  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
248
+ execute *DASH.auditor.record_then("Pulled image with version #{DASH.config.version}",
249
+ DASH.builder.clean_then_pull)
194
250
  execute *DASH.builder.validate_image
195
251
  end
196
252
  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