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
@@ -6,6 +6,27 @@ require "json"
6
6
  require "resolv"
7
7
  require "concurrent/atomic/semaphore"
8
8
 
9
+ # Deploy-report timing reaches into dash's runtime from code that runs for everyone else
10
+ # using SSHKit in this process too, and this file can be required on its own — without the
11
+ # gem's autoloader and without the DASH commander. So every reach is optional, and it is
12
+ # funnelled through here rather than repeated at each hook, where one site would sooner or
13
+ # later forget the guard and take down every parallel run with a NameError.
14
+ module DashTimings
15
+ class << self
16
+ def timings
17
+ DASH.timings if defined?(DASH)
18
+ end
19
+
20
+ def current_entry
21
+ Dash::Timings.current_entry if defined?(Dash::Timings)
22
+ end
23
+
24
+ def current_entry=(entry)
25
+ Dash::Timings.current_entry = entry if defined?(Dash::Timings)
26
+ end
27
+ end
28
+ end
29
+
9
30
  class SSHKit::Backend::Abstract
10
31
  def capture_with_info(*args, **kwargs)
11
32
  capture(*args, **kwargs, verbosity: Logger::INFO)
@@ -64,6 +85,22 @@ class SSHKit::Backend::Abstract
64
85
  end
65
86
  end
66
87
  prepend CommandEnvMerge
88
+
89
+ # Attributes the wall time of every command to the timing entry that is current on
90
+ # this thread, so a phase can report how much of its total was spent waiting on round
91
+ # trips rather than on the app. Nothing is executed that would not have run anyway —
92
+ # this only stamps the commands dash was already issuing.
93
+ module TimedCommands
94
+ private
95
+ def create_command_and_execute(args, options)
96
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
+
98
+ super
99
+ ensure
100
+ DashTimings.timings&.attribute_command(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, local: !!host&.local?)
101
+ end
102
+ end
103
+ prepend TimedCommands
67
104
  end
68
105
 
69
106
  class SSHKit::Backend::Netssh::Configuration
@@ -171,6 +208,21 @@ class SSHKit::Backend::Netssh
171
208
  end
172
209
  prepend LimitConcurrentStartsInstance
173
210
 
211
+ # Prepended last, so it is in front of the concurrency limiter and the DNS retries:
212
+ # what a phase pays for a connection includes queueing behind max_concurrent_starts.
213
+ # The pool only calls through on a cache miss, so this measures real connects.
214
+ module TimedConnects
215
+ private
216
+ def connect_ssh(...)
217
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+
219
+ super
220
+ ensure
221
+ DashTimings.timings&.attribute_connect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
222
+ end
223
+ end
224
+ prepend TimedConnects
225
+
174
226
  # A pooled session that sat idle while dash was busy elsewhere (a server-lock
175
227
  # wait, a loadbalancer reboot) gets dropped by NATs and cloud networks without
176
228
  # either end noticing: net-ssh only sends keepalives from inside its event
@@ -235,9 +287,15 @@ class SSHKit::Runner::Parallel
235
287
  # problem occurs on multiple hosts.
236
288
  module CompleteAll
237
289
  def execute
290
+ # A new thread starts with none of its parent's thread-locals, so the timing entry
291
+ # has to be handed over explicitly or every command run on a host would be
292
+ # attributed to no phase at all.
293
+ timing_entry = DashTimings.current_entry
294
+
238
295
  threads = hosts.map do |host|
239
296
  Thread.new(host) do |h|
240
297
  Thread.current.report_on_exception = false
298
+ DashTimings.current_entry = timing_entry
241
299
  backend(h, &block).run
242
300
  rescue ::StandardError => e
243
301
  e2 = SSHKit::Runner::ExecuteError.new e
@@ -315,9 +373,13 @@ module SSHKitDslRoles
315
373
  # end
316
374
  def on_roles(roles, hosts:, parallel: true, rolling: false, &block)
317
375
  if parallel
376
+ # See CompleteAll#execute: thread-locals do not cross Thread.new.
377
+ timing_entry = DashTimings.current_entry
378
+
318
379
  threads = roles.filter_map do |role|
319
380
  if (role_hosts = role.hosts & hosts).any?
320
381
  Thread.new do
382
+ DashTimings.current_entry = timing_entry
321
383
  on(role_hosts, rolling ? role.boot_runner_options(role_hosts) : {}) { |host| instance_exec(host, role, &block) }
322
384
  rescue StandardError => e
323
385
  raise SSHKit::Runner::ExecuteError.new(e), "Exception while executing on #{role}: #{e.message}"
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.8"
2
+ VERSION = "4.1.1"
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.8
4
+ version: 4.1.1
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
@@ -198,6 +201,7 @@ files:
198
201
  - lib/dash/cli/healthcheck/drift_error.rb
199
202
  - lib/dash/cli/healthcheck/error.rb
200
203
  - lib/dash/cli/healthcheck/poller.rb
204
+ - lib/dash/cli/healthcheck/progress_reporter.rb
201
205
  - lib/dash/cli/lock.rb
202
206
  - lib/dash/cli/main.rb
203
207
  - lib/dash/cli/main/migrate.rb
@@ -209,6 +213,7 @@ files:
209
213
  - lib/dash/cli/proxy/reboot.rb
210
214
  - lib/dash/cli/prune.rb
211
215
  - lib/dash/cli/registry.rb
216
+ - lib/dash/cli/report.rb
212
217
  - lib/dash/cli/secrets.rb
213
218
  - lib/dash/cli/server.rb
214
219
  - lib/dash/cli/templates/deploy.yml
@@ -255,6 +260,7 @@ files:
255
260
  - lib/dash/commands/lock.rb
256
261
  - lib/dash/commands/proxy.rb
257
262
  - lib/dash/commands/proxy/cert_transfer.rb
263
+ - lib/dash/commands/proxy/state.rb
258
264
  - lib/dash/commands/prune.rb
259
265
  - lib/dash/commands/registry.rb
260
266
  - lib/dash/commands/server.rb
@@ -273,6 +279,7 @@ files:
273
279
  - lib/dash/configuration/docs/output.yml
274
280
  - lib/dash/configuration/docs/proxy.yml
275
281
  - lib/dash/configuration/docs/registry.yml
282
+ - lib/dash/configuration/docs/report.yml
276
283
  - lib/dash/configuration/docs/role.yml
277
284
  - lib/dash/configuration/docs/servers.yml
278
285
  - lib/dash/configuration/docs/ssh.yml
@@ -287,6 +294,7 @@ files:
287
294
  - lib/dash/configuration/proxy/boot.rb
288
295
  - lib/dash/configuration/proxy/run.rb
289
296
  - lib/dash/configuration/registry.rb
297
+ - lib/dash/configuration/report.rb
290
298
  - lib/dash/configuration/role.rb
291
299
  - lib/dash/configuration/role/healthcheck.rb
292
300
  - lib/dash/configuration/servers.rb
@@ -305,6 +313,31 @@ files:
305
313
  - lib/dash/configuration/validator/servers.rb
306
314
  - lib/dash/configuration/volume.rb
307
315
  - lib/dash/docker.rb
316
+ - lib/dash/dockerfile/analyzer.rb
317
+ - lib/dash/dockerfile/context.rb
318
+ - lib/dash/dockerfile/dockerignore.rb
319
+ - lib/dash/dockerfile/document.rb
320
+ - lib/dash/dockerfile/finding.rb
321
+ - lib/dash/dockerfile/hadolint.rb
322
+ - lib/dash/dockerfile/instruction.rb
323
+ - lib/dash/dockerfile/parser.rb
324
+ - lib/dash/dockerfile/rules/apt_hygiene.rb
325
+ - lib/dash/dockerfile/rules/base.rb
326
+ - lib/dash/dockerfile/rules/cache_busting_arg.rb
327
+ - lib/dash/dockerfile/rules/cache_export_cost.rb
328
+ - lib/dash/dockerfile/rules/context_size.rb
329
+ - lib/dash/dockerfile/rules/copy_before_install.rb
330
+ - lib/dash/dockerfile/rules/curl_pipe_shell.rb
331
+ - lib/dash/dockerfile/rules/dockerignore_gaps.rb
332
+ - lib/dash/dockerfile/rules/inline_env_blob.rb
333
+ - lib/dash/dockerfile/rules/latest_base.rb
334
+ - lib/dash/dockerfile/rules/missing_dockerignore.rb
335
+ - lib/dash/dockerfile/rules/no_cache_mount.rb
336
+ - lib/dash/dockerfile/rules/root_user.rb
337
+ - lib/dash/dockerfile/rules/secret_in_build_arg.rb
338
+ - lib/dash/dockerfile/rules/single_stage_build_deps.rb
339
+ - lib/dash/dockerfile/rules/uncached_install.rb
340
+ - lib/dash/dockerfile/stage.rb
308
341
  - lib/dash/env_file.rb
309
342
  - lib/dash/git.rb
310
343
  - lib/dash/otel_shipper.rb
@@ -313,6 +346,10 @@ files:
313
346
  - lib/dash/output/formatter.rb
314
347
  - lib/dash/output/otel_logger.rb
315
348
  - lib/dash/project_directory.rb
349
+ - lib/dash/report.rb
350
+ - lib/dash/report/history.rb
351
+ - lib/dash/report/trends.rb
352
+ - lib/dash/report/writer.rb
316
353
  - lib/dash/secrets.rb
317
354
  - lib/dash/secrets/adapters.rb
318
355
  - lib/dash/secrets/adapters/aws_secrets_manager.rb