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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a417156134dd79a4724f87192f70baa55fdbc4478a115e021845e3742bacee88
4
- data.tar.gz: ebdbb8d7f192b692c31c8cdaa9834584c01dec3653b744b1a481672fee179d28
3
+ metadata.gz: bb6c271cec9b9ab118649ffeaf503aa5ba31bd8d0f8057ce0a61adb93e130ef4
4
+ data.tar.gz: ce7f60b8e4a764e06ab868663f825134d8f73cac0e11f663793e6a12471ab490
5
5
  SHA512:
6
- metadata.gz: 3b263730b7ccf4eeeb857304477945fd9d2476bdf52cdb30a964b12b09143b36b5f585ae672d776eac29a6e009606add151aa6e9dd98385f9146980bbc40a9db
7
- data.tar.gz: a3ebb3e0141a0f692bd70205aa3d438f9a3cbd1022d1b92c229bf1daf19294c4826a7420c0fefbb077f5a6d6cb46016ee6ab3926bb08f11383bb7a6eb0aaa23e
6
+ metadata.gz: 35204403e1680a42ce0b0726225d68c55119a74b387a9cd45189822499e5aa225c250ad82190944cbd4a5ff7aa976b3dcf02600e44b92a8a4ed165f86123c96b
7
+ data.tar.gz: cb520eb7f9028fad75e4537afcdcc1c7aea71aa6cb58c35a33b9c8d185781a4de9ad7a9f3d1f1f8daba8b73390b279bbd292ac1a82d531a303f34086c69c28d3
@@ -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
@@ -1,4 +1,9 @@
1
1
  class Dash::Cli::App::Boot
2
+ # What `docker container ls --quiet` prints, and so what dash-proxy has always been
3
+ # handed as a target. `docker run --detach` prints the full 64-character id, so the
4
+ # target is its first twelve characters rather than a round trip of its own.
5
+ SHORT_CONTAINER_ID_LENGTH = 12
6
+
2
7
  attr_reader :host, :role, :version, :barrier, :sshkit, :cli
3
8
  delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit
4
9
  delegate :run_hook, to: :cli
@@ -38,27 +43,44 @@ class Dash::Cli::App::Boot
38
43
  end
39
44
 
40
45
  private
46
+ # Both answers come back from one round trip, which means the running version is read
47
+ # before any rename happens. When the clashing container IS the running one, the
48
+ # version to stop later is the name it was renamed to - the name that was read now
49
+ # belongs to the container this boot is about to start.
41
50
  def old_version_renamed_if_clashing
42
- if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
51
+ clashing_container_id, old_version = capture_boot_state
52
+
53
+ if clashing_container_id.present?
43
54
  renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
44
55
  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)
56
+ execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
57
+ app.rename_container(version: version, new_version: renamed_version))
58
+
59
+ old_version = renamed_version if old_version == version
47
60
  end
48
61
 
49
- capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence
62
+ old_version
63
+ end
64
+
65
+ def capture_boot_state
66
+ clashing, running = Dash::Commands::App.split_state(capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false))
67
+
68
+ [ clashing.strip.presence, running.strip.presence ]
50
69
  end
51
70
 
52
71
  def start_new_version
53
- audit "Booted app version #{version}"
54
72
  hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
55
73
 
56
- execute *app.ensure_env_directory
74
+ execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
57
75
  upload! role.secrets_io(host), role.secrets_path, mode: "0600"
58
76
 
59
- execute *app.run(hostname: hostname)
77
+ # `docker run --detach` prints the id of the container it just started, so the
78
+ # proxy target comes out of the run itself — asking docker for it again was a round
79
+ # trip spent re-reading something the host had already said.
80
+ container_id = capture_with_info(*app.run(hostname: hostname)).strip
81
+
60
82
  if running_proxy?
61
- endpoint = capture_with_info(*app.container_id_for_version(version)).strip
83
+ endpoint = container_id[0, SHORT_CONTAINER_ID_LENGTH]
62
84
  raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
63
85
 
64
86
  run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
@@ -66,7 +88,7 @@ class Dash::Cli::App::Boot
66
88
  timing_healthy { execute *app.deploy(target: endpoint) }
67
89
  run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
68
90
  else
69
- timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { health_status } }
91
+ timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role, &method(:readiness_status)) }
70
92
  end
71
93
  rescue => e
72
94
  error "Failed to boot #{role} on #{host}"
@@ -74,17 +96,21 @@ class Dash::Cli::App::Boot
74
96
  raise e
75
97
  end
76
98
 
77
- # An exec probe is docker-invisible the container declares no healthcheck, so
78
- # `docker inspect` would only ever report its state. Poll the probe instead.
79
- def health_status
80
- role.healthcheck&.exec? ? exec_probe_status : capture_with_info(*app.status(version: version))
81
- end
82
-
83
- def exec_probe_status
84
- execute *app.health_probe(version: version)
85
- "healthy"
86
- rescue SSHKit::Command::Failed
87
- "exec probe exited non-zero"
99
+ # A role behind the proxy lets `dash-proxy deploy` block on the host until the
100
+ # container is healthy; a role without one now does the same, waiting in a shell loop
101
+ # on the host that streams its progress back rather than being polled from here once
102
+ # per attempt. The poller asks for the wait, and — only for an unchecked container it
103
+ # has just let through its readiness delay — for a plain confirming read.
104
+ #
105
+ # Neither capture suppresses a non-zero exit: a status that cannot be read is a broken
106
+ # command, and it has always failed the boot on the spot rather than being waited out.
107
+ def readiness_status(mode, seconds_left = nil)
108
+ if mode == :confirm
109
+ capture_with_info(*app.status(version: version))
110
+ else
111
+ capture_with_info *app.wait_for_ready(version: version, timeout: seconds_left),
112
+ interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
113
+ end
88
114
  end
89
115
 
90
116
  # Every failed boot gets the container log, and the health probe history when the
@@ -161,10 +187,6 @@ class Dash::Cli::App::Boot
161
187
  @auditor = DASH.auditor(role: role)
162
188
  end
163
189
 
164
- def audit(message)
165
- execute *auditor.record(message), verbosity: :debug
166
- end
167
-
168
190
  def gatekeeper?
169
191
  barrier && barrier_role?
170
192
  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
@@ -186,8 +186,8 @@ class Dash::Cli::App < Dash::Cli::Base
186
186
  with_lock_if_stopping do
187
187
  on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
188
188
  app = DASH.app(role: role, host: host)
189
- versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n")
190
- versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ]
189
+ listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
190
+ versions = listed.strip.split("\n") - [ running.strip ]
191
191
 
192
192
  versions.each do |version|
193
193
  if stop
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)