dash 4.0.7 → 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 (63) 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/docs/ssh.yml +8 -0
  23. data/lib/dash/configuration/docs/sshkit.yml +2 -1
  24. data/lib/dash/configuration/report.rb +65 -0
  25. data/lib/dash/configuration/ssh.rb +8 -1
  26. data/lib/dash/configuration.rb +2 -1
  27. data/lib/dash/dockerfile/analyzer.rb +66 -0
  28. data/lib/dash/dockerfile/context.rb +147 -0
  29. data/lib/dash/dockerfile/dockerignore.rb +29 -0
  30. data/lib/dash/dockerfile/document.rb +28 -0
  31. data/lib/dash/dockerfile/finding.rb +20 -0
  32. data/lib/dash/dockerfile/hadolint.rb +75 -0
  33. data/lib/dash/dockerfile/instruction.rb +58 -0
  34. data/lib/dash/dockerfile/parser.rb +199 -0
  35. data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
  36. data/lib/dash/dockerfile/rules/base.rb +44 -0
  37. data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
  38. data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
  39. data/lib/dash/dockerfile/rules/context_size.rb +20 -0
  40. data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
  41. data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
  42. data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
  43. data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
  44. data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
  45. data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
  46. data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
  47. data/lib/dash/dockerfile/rules/root_user.rb +12 -0
  48. data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
  49. data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
  50. data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
  51. data/lib/dash/dockerfile/stage.rb +65 -0
  52. data/lib/dash/otel_shipper.rb +5 -4
  53. data/lib/dash/output/otel_logger.rb +52 -0
  54. data/lib/dash/report/history.rb +94 -0
  55. data/lib/dash/report/trends.rb +129 -0
  56. data/lib/dash/report/writer.rb +142 -0
  57. data/lib/dash/report.rb +170 -0
  58. data/lib/dash/sshkit_with_ext.rb +77 -4
  59. data/lib/dash/timings.rb +163 -10
  60. data/lib/dash/utils.rb +7 -0
  61. data/lib/dash/version.rb +1 -1
  62. data/lib/dash.rb +4 -0
  63. metadata +36 -1
data/lib/dash/timings.rb CHANGED
@@ -5,40 +5,193 @@
5
5
  # Entries land in start order and carry a depth, so a parent phase (Boot) prints above
6
6
  # the host entries it wraps even though the hosts finish first. Boot runs one thread per
7
7
  # host, so every mutation is behind the mutex.
8
+ #
9
+ # Each entry also accounts for the commands issued while it was the current phase, which
10
+ # is what separates "the boot took 55s" from "the boot spent 41s of that in forty serial
11
+ # SSH round trips". Attribution is by thread-local: `phase` marks its entry as current for
12
+ # the duration of the block, and `lib/dash/sshkit_with_ext.rb` stamps every command and
13
+ # every SSH connect onto whatever entry is current on that thread. Nothing extra is
14
+ # executed — dash only measures the round trips it was already making.
8
15
  class Dash::Timings
9
- Entry = Struct.new(:name, :seconds, :detail, :depth)
16
+ # The entry the current thread is inside. SSHKit's per-host threads inherit it from
17
+ # the thread that spawned them (see CompleteAll#execute and SSHKitDslRoles#on_roles),
18
+ # so a command issued on a boot thread lands on that host's row.
19
+ CURRENT_KEY = :dash_timing_entry
20
+
21
+ Entry = Struct.new(:name, :seconds, :detail, :depth, :parent, :commands, :command_seconds, :connect_seconds, :local)
22
+
23
+ # Read and written from lib/dash/sshkit_with_ext.rb, which hands the entry across the
24
+ # per-host and per-role threads SSHKit spawns. Class-level because the patches run
25
+ # without a commander in reach.
26
+ class << self
27
+ def current_entry
28
+ Thread.current[CURRENT_KEY]
29
+ end
10
30
 
11
- def initialize
12
- @entries = []
31
+ def current_entry=(entry)
32
+ Thread.current[CURRENT_KEY] = entry
33
+ end
34
+
35
+ # Rebuilds a table from what #to_h exported, so `dash report` can print a saved
36
+ # deploy the way the deploy printed it. The entries come back parentless: their
37
+ # counters are already the subtree totals #to_h computed, and re-nesting them would
38
+ # roll those totals up a second time.
39
+ def from_h(phases)
40
+ new entries: Array(phases).map { |phase| entry_from(phase.transform_keys(&:to_sym)) }
41
+ end
42
+
43
+ private
44
+ def entry_from(phase)
45
+ Entry.new \
46
+ phase[:name], phase[:seconds].to_f, phase[:detail], phase[:depth].to_i, nil,
47
+ phase[:commands].to_i, phase[:command_seconds].to_f, phase[:connect_seconds].to_f,
48
+ phase[:local].nil? ? true : phase[:local]
49
+ end
50
+ end
51
+
52
+ def initialize(entries: [])
53
+ @entries = entries
13
54
  @mutex = Mutex.new
14
55
  end
15
56
 
16
57
  # Times the block. The entry is yielded so the block can annotate it — a boot can say
17
58
  # how long of its total was spent waiting for the container to become healthy.
18
59
  def phase(name, depth: 0)
19
- entry = Entry.new(name, nil, nil, depth)
60
+ entry = new_entry(name, depth: depth)
20
61
  @mutex.synchronize { @entries << entry }
21
62
  started = clock
22
63
 
23
- yield entry
64
+ previous = self.class.current_entry
65
+ self.class.current_entry = entry
66
+
67
+ begin
68
+ yield entry
69
+ ensure
70
+ self.class.current_entry = previous
71
+ end
24
72
  ensure
25
73
  entry.seconds = clock - started
26
74
  end
27
75
 
76
+ # For time that was measured elsewhere — the gem load and config parse that happened
77
+ # before there was a Timings to record into.
78
+ def record(name, seconds, depth: 0, detail: nil)
79
+ new_entry(name, depth: depth).tap do |entry|
80
+ entry.seconds = seconds
81
+ entry.detail = detail
82
+ @mutex.synchronize { @entries << entry }
83
+ end
84
+ end
85
+
86
+ def current
87
+ self.class.current_entry
88
+ end
89
+
90
+ def attribute_command(seconds, local:)
91
+ entry = current or return
92
+
93
+ @mutex.synchronize do
94
+ entry.commands += 1
95
+ entry.command_seconds += seconds
96
+ # A phase that issued even one remote command is reported as ssh: the local
97
+ # label is only honest when nothing left the machine.
98
+ entry.local &&= local
99
+ end
100
+ end
101
+
102
+ def attribute_connect(seconds)
103
+ entry = current or return
104
+
105
+ @mutex.synchronize { entry.connect_seconds += seconds }
106
+ end
107
+
28
108
  def any?
29
109
  @mutex.synchronize { @entries.any? }
30
110
  end
31
111
 
112
+ # Where an entry's row sits in #lines, so Dash::Report can splice its build rows in
113
+ # under the phase they belong to. Identity, not equality: two phases of the same name
114
+ # and duration are equal as Structs but are not the same row.
115
+ # The row at a position, so a consumer that saved an index (Dash::Report, reattaching
116
+ # its build rows to the phase they hung under) can find the entry again.
117
+ def entry_at(index)
118
+ @mutex.synchronize { @entries[index] } if index
119
+ end
120
+
121
+ def index_of(entry)
122
+ @mutex.synchronize { @entries.index { |candidate| candidate.equal?(entry) } }
123
+ end
124
+
32
125
  def lines
33
- @mutex.synchronize do
34
- @entries.map do |entry|
35
- line = format(" %s%-36s %6.1fs", " " * entry.depth, entry.name, entry.seconds.to_f)
36
- entry.detail ? "#{line} (#{entry.detail})" : line
37
- end
126
+ with_totals do |entry, totals|
127
+ line = format(" %s%-36s %6.1fs", " " * entry.depth, entry.name, entry.seconds.to_f)
128
+ line += format(" %3d %-5s %5.1fs", totals[:commands], totals[:local] ? "local" : "ssh", totals[:command_seconds]) if totals[:commands] > 0
129
+ entry.detail ? "#{line} (#{entry.detail})" : line
130
+ end
131
+ end
132
+
133
+ # A top-level phase's wall time by name, for consumers that know a phase by what it is
134
+ # called rather than by where it sits — the post-deploy hook's build and boot runtimes.
135
+ # Depth-0 only: a per-host row inside Boot is named after the host, but a role could be
136
+ # named "Boot" and must not be mistaken for the phase.
137
+ def seconds_for(name)
138
+ @mutex.synchronize { @entries.find { |entry| entry.name == name && entry.depth.to_i.zero? }&.seconds }
139
+ end
140
+
141
+ # The command counts here are subtree totals, matching what the table prints — phases
142
+ # nest, so a consumer must not sum them across depths.
143
+ def to_h
144
+ with_totals do |entry, totals|
145
+ {
146
+ name: entry.name,
147
+ depth: entry.depth,
148
+ seconds: entry.seconds.to_f,
149
+ detail: entry.detail,
150
+ commands: totals[:commands],
151
+ command_seconds: totals[:command_seconds],
152
+ connect_seconds: totals[:connect_seconds],
153
+ local: totals[:local]
154
+ }
38
155
  end
39
156
  end
40
157
 
41
158
  private
159
+ def new_entry(name, depth:)
160
+ Entry.new(name, nil, nil, depth, current, 0, 0.0, 0.0, true)
161
+ end
162
+
163
+ def with_totals
164
+ @mutex.synchronize do
165
+ totals = subtree_totals
166
+ @entries.map { |entry| yield entry, totals[entry.object_id] }
167
+ end
168
+ end
169
+
170
+ # A parent phase issues few commands of its own — Boot opens one thread per host and
171
+ # then waits — so its own counters say nothing. Roll each entry's counters up through
172
+ # its ancestors at render time rather than at record time, so the host rows keep
173
+ # their own numbers while the parent shows what the phase cost in total.
174
+ def subtree_totals
175
+ totals = @entries.to_h { |entry| [ entry.object_id, { commands: 0, command_seconds: 0.0, connect_seconds: 0.0, local: true } ] }
176
+
177
+ @entries.each do |entry|
178
+ node = entry
179
+
180
+ while node
181
+ if (total = totals[node.object_id])
182
+ total[:commands] += entry.commands
183
+ total[:command_seconds] += entry.command_seconds
184
+ total[:connect_seconds] += entry.connect_seconds
185
+ total[:local] &&= entry.local if entry.commands > 0
186
+ end
187
+
188
+ node = node.parent
189
+ end
190
+ end
191
+
192
+ totals
193
+ end
194
+
42
195
  def clock
43
196
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
197
  end
data/lib/dash/utils.rb CHANGED
@@ -134,6 +134,13 @@ module Dash::Utils
134
134
  end
135
135
  end
136
136
 
137
+ # buildx reports decimal units, so dash does too — an operator comparing a report row
138
+ # with what buildx printed should see the same number.
139
+ def human_bytes(bytes)
140
+ divisor, unit = [ [ 1_000_000_000, "GB" ], [ 1_000_000, "MB" ], [ 1_000, "kB" ] ].find { |size, _| bytes >= size }
141
+ divisor ? format("%.1f%s", bytes.to_f / divisor, unit) : "#{bytes}B"
142
+ end
143
+
137
144
  def older_version?(version, other_version)
138
145
  Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v"))
139
146
  end
data/lib/dash/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dash
2
- VERSION = "4.0.7"
2
+ VERSION = "4.1.0"
3
3
  end
data/lib/dash.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  module Dash
2
2
  class ConfigurationError < StandardError; end
3
+
4
+ # Stamped before the first require so the Startup row in the deploy report covers what
5
+ # it actually costs to get here: gem load, Zeitwerk, and everything bin/dash pulls in.
6
+ PROCESS_STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)
3
7
  end
4
8
 
5
9
  require "active_support"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dash
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.7
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -177,6 +177,9 @@ files:
177
177
  - README.md
178
178
  - bin/dash
179
179
  - lib/dash.rb
180
+ - lib/dash/build/progress_parser.rb
181
+ - lib/dash/build/report.rb
182
+ - lib/dash/build/step.rb
180
183
  - lib/dash/cli.rb
181
184
  - lib/dash/cli/accessory.rb
182
185
  - lib/dash/cli/alias/command.rb
@@ -209,6 +212,7 @@ files:
209
212
  - lib/dash/cli/proxy/reboot.rb
210
213
  - lib/dash/cli/prune.rb
211
214
  - lib/dash/cli/registry.rb
215
+ - lib/dash/cli/report.rb
212
216
  - lib/dash/cli/secrets.rb
213
217
  - lib/dash/cli/server.rb
214
218
  - lib/dash/cli/templates/deploy.yml
@@ -273,6 +277,7 @@ files:
273
277
  - lib/dash/configuration/docs/output.yml
274
278
  - lib/dash/configuration/docs/proxy.yml
275
279
  - lib/dash/configuration/docs/registry.yml
280
+ - lib/dash/configuration/docs/report.yml
276
281
  - lib/dash/configuration/docs/role.yml
277
282
  - lib/dash/configuration/docs/servers.yml
278
283
  - lib/dash/configuration/docs/ssh.yml
@@ -287,6 +292,7 @@ files:
287
292
  - lib/dash/configuration/proxy/boot.rb
288
293
  - lib/dash/configuration/proxy/run.rb
289
294
  - lib/dash/configuration/registry.rb
295
+ - lib/dash/configuration/report.rb
290
296
  - lib/dash/configuration/role.rb
291
297
  - lib/dash/configuration/role/healthcheck.rb
292
298
  - lib/dash/configuration/servers.rb
@@ -305,6 +311,31 @@ files:
305
311
  - lib/dash/configuration/validator/servers.rb
306
312
  - lib/dash/configuration/volume.rb
307
313
  - lib/dash/docker.rb
314
+ - lib/dash/dockerfile/analyzer.rb
315
+ - lib/dash/dockerfile/context.rb
316
+ - lib/dash/dockerfile/dockerignore.rb
317
+ - lib/dash/dockerfile/document.rb
318
+ - lib/dash/dockerfile/finding.rb
319
+ - lib/dash/dockerfile/hadolint.rb
320
+ - lib/dash/dockerfile/instruction.rb
321
+ - lib/dash/dockerfile/parser.rb
322
+ - lib/dash/dockerfile/rules/apt_hygiene.rb
323
+ - lib/dash/dockerfile/rules/base.rb
324
+ - lib/dash/dockerfile/rules/cache_busting_arg.rb
325
+ - lib/dash/dockerfile/rules/cache_export_cost.rb
326
+ - lib/dash/dockerfile/rules/context_size.rb
327
+ - lib/dash/dockerfile/rules/copy_before_install.rb
328
+ - lib/dash/dockerfile/rules/curl_pipe_shell.rb
329
+ - lib/dash/dockerfile/rules/dockerignore_gaps.rb
330
+ - lib/dash/dockerfile/rules/inline_env_blob.rb
331
+ - lib/dash/dockerfile/rules/latest_base.rb
332
+ - lib/dash/dockerfile/rules/missing_dockerignore.rb
333
+ - lib/dash/dockerfile/rules/no_cache_mount.rb
334
+ - lib/dash/dockerfile/rules/root_user.rb
335
+ - lib/dash/dockerfile/rules/secret_in_build_arg.rb
336
+ - lib/dash/dockerfile/rules/single_stage_build_deps.rb
337
+ - lib/dash/dockerfile/rules/uncached_install.rb
338
+ - lib/dash/dockerfile/stage.rb
308
339
  - lib/dash/env_file.rb
309
340
  - lib/dash/git.rb
310
341
  - lib/dash/otel_shipper.rb
@@ -313,6 +344,10 @@ files:
313
344
  - lib/dash/output/formatter.rb
314
345
  - lib/dash/output/otel_logger.rb
315
346
  - lib/dash/project_directory.rb
347
+ - lib/dash/report.rb
348
+ - lib/dash/report/history.rb
349
+ - lib/dash/report/trends.rb
350
+ - lib/dash/report/writer.rb
316
351
  - lib/dash/secrets.rb
317
352
  - lib/dash/secrets/adapters.rb
318
353
  - lib/dash/secrets/adapters/aws_secrets_manager.rb