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
@@ -0,0 +1,20 @@
1
+ # Measured only: mode=max exports every intermediate layer to the cache. On a persistent
2
+ # builder that is often pure overhead — but only the numbers from this build can say so.
3
+ class Dash::Dockerfile::Rules::CacheExportCost < Dash::Dockerfile::Rules::Base
4
+ SHARE = 0.2
5
+ SUGGESTION = "try mode=min under builder: cache: options:"
6
+
7
+ def findings
8
+ return [] unless build && mode_max?
9
+
10
+ exported, total = build.cache_export_seconds, build.total_step_seconds
11
+ return [] if total.zero? || exported <= total * SHARE
12
+
13
+ [ note("builder.cache", format("exporting the build cache took %.1fs of %.1fs with mode=max", exported, total), SUGGESTION) ]
14
+ end
15
+
16
+ private
17
+ def mode_max?
18
+ builder&.cache_to.to_s.split(",").map(&:strip).include?("mode=max")
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # Measured only: how many megabytes this build actually shipped to the builder before it
2
+ # ran a single instruction.
3
+ class Dash::Dockerfile::Rules::ContextSize < Dash::Dockerfile::Rules::Base
4
+ THRESHOLD_BYTES = 50_000_000
5
+ SUGGESTION = "trim the build context with .dockerignore, or point builder: context: at a smaller directory"
6
+
7
+ def findings
8
+ bytes = build&.context_bytes
9
+ return [] if bytes.to_i <= THRESHOLD_BYTES
10
+
11
+ [ warning("build context", "the build shipped #{Dash::Utils.human_bytes(bytes)}#{elapsed} of build context to the builder", SUGGESTION) ]
12
+ end
13
+
14
+ private
15
+ # A build killed mid-transfer has a size but no duration; "in 0.0s" would be a
16
+ # measurement nobody took.
17
+ def elapsed
18
+ format(" in %.1fs", build.context_seconds) if build.context_seconds
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ # The single most expensive Dockerfile mistake: copying the whole tree before installing
2
+ # dependencies, so every commit — a README typo included — reinstalls them.
3
+ class Dash::Dockerfile::Rules::CopyBeforeInstall < Dash::Dockerfile::Rules::Base
4
+ SUGGESTION = "copy the dependency manifests first (Gemfile, package.json, lockfiles), install, then copy the rest of the tree"
5
+
6
+ def findings
7
+ document.stages.filter_map { |stage| finding_for(stage) }
8
+ end
9
+
10
+ private
11
+ def finding_for(stage)
12
+ copy = stage.instructions.find { |instruction| context.broad_copy?(instruction) } or return
13
+ install = stage.instructions.find do |instruction|
14
+ instruction.line > copy.line && context.dependency_install?(instruction)
15
+ end
16
+ return unless install
17
+
18
+ warning at(copy), message(copy, install), SUGGESTION
19
+ end
20
+
21
+ def message(copy, install)
22
+ [ "#{copy.name} #{copy.args} runs before `#{context.install_command(install)}` (line #{install.line}), ",
23
+ "so dependencies reinstall on every commit", measured(install) ].compact.join
24
+ end
25
+
26
+ # Only when the build that just ran proves it: a cached install step costs nothing,
27
+ # and advice that names a number nobody measured is worse than advice that doesn't.
28
+ def measured(install)
29
+ step = context.build_step_for(install)
30
+ return if step.nil? || step.cached || step.seconds.to_f.zero?
31
+
32
+ format(" (measured %.1fs uncached)", step.seconds)
33
+ end
34
+ end
@@ -0,0 +1,16 @@
1
+ # Piping a download straight into a shell runs whatever the server sends today, and there
2
+ # is no version in the Dockerfile to say what that was.
3
+ class Dash::Dockerfile::Rules::CurlPipeShell < Dash::Dockerfile::Rules::Base
4
+ # `sudo` may carry flags with or without arguments (`-E`, `-u root`) before the shell.
5
+ SHELL = /(?:sudo\s+(?:-\S+(?:\s+[^-\s]\S*)?\s+)*)?(?:ba|z|k)?sh\b/
6
+ PIPE_TO_SHELL = /\b(?:curl|wget)\b[^|]*\|\s*#{SHELL.source}|#{SHELL.source}\s+<\(\s*(?:curl|wget)\b/
7
+ SUGGESTION = "download to a file, verify a checksum, then run it — and pin the version"
8
+
9
+ def findings
10
+ document.instructions.filter_map do |instruction|
11
+ next unless instruction.name == "RUN" && instruction.shell_command.match?(PIPE_TO_SHELL)
12
+
13
+ note at(instruction), "a download is piped straight into a shell, so the build runs unverified code", SUGGESTION
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # A .dockerignore that misses the obvious offenders. .git is always worth naming; the
2
+ # rest only when the directory actually exists in the context.
3
+ class Dash::Dockerfile::Rules::DockerignoreGaps < Dash::Dockerfile::Rules::Base
4
+ ALWAYS = ".git".freeze
5
+ WHEN_PRESENT = %w[ node_modules tmp storage coverage log .env* ].freeze
6
+ SUGGESTION = "add them to .dockerignore so they are not sent to the builder"
7
+
8
+ def findings
9
+ return [] if dockerignore.nil?
10
+ return [] if (gaps = gaps()).empty?
11
+
12
+ [ finding(severity, Dash::Dockerfile::Dockerignore::FILENAME, message(gaps), SUGGESTION) ]
13
+ end
14
+
15
+ private
16
+ def gaps
17
+ candidates = [ ALWAYS, *WHEN_PRESENT.flat_map { |name| context.context_entries(name) } ]
18
+ candidates.uniq.reject { |name| dockerignore.covers?(name) }
19
+ end
20
+
21
+ # A context big enough to be worth seconds of every build turns the same gap from a
22
+ # tidiness note into something that is costing the operator time.
23
+ def severity
24
+ big_context? ? :warn : :info
25
+ end
26
+
27
+ def message(gaps)
28
+ measured = " — the measured build context was #{Dash::Utils.human_bytes(build.context_bytes)}" if big_context?
29
+
30
+ "the build context ships #{gaps.join(", ")}#{measured}"
31
+ end
32
+
33
+ def big_context?
34
+ build&.context_bytes.to_i > Dash::Dockerfile::Rules::ContextSize::THRESHOLD_BYTES
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ # A wall of inline assignments in front of a command means editing any one value
2
+ # invalidates the layer — and makes the instruction unreadable in the bargain.
3
+ class Dash::Dockerfile::Rules::InlineEnvBlob < Dash::Dockerfile::Rules::Base
4
+ ASSIGNMENT = /\A[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\\.|\S)*\s+/
5
+ THRESHOLD = 20
6
+ SUGGESTION = "read them from an env file at runtime, or set the build-time ones in one ENV block, so the command line stops changing"
7
+
8
+ def findings
9
+ document.instructions.filter_map do |instruction|
10
+ next unless instruction.name == "RUN"
11
+ next unless (count = leading_assignments(instruction.shell_command.dup)) > THRESHOLD
12
+
13
+ note at(instruction), "#{count} inline environment assignments precede the command", SUGGESTION
14
+ end
15
+ end
16
+
17
+ private
18
+ def leading_assignments(command)
19
+ count = 0
20
+ count += 1 while command.sub!(ASSIGNMENT, "")
21
+ count
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # An unpinned base image makes a build unreproducible: the same Dockerfile builds a
2
+ # different image tomorrow, and nothing in the deploy says so.
3
+ class Dash::Dockerfile::Rules::LatestBase < Dash::Dockerfile::Rules::Base
4
+ SUGGESTION = "pin a specific tag, or a digest for a build that must be reproducible"
5
+
6
+ def findings
7
+ document.stages.filter_map { |stage| finding_for(stage) }
8
+ end
9
+
10
+ private
11
+ def finding_for(stage)
12
+ return if built_on_another_stage?(stage) || stage.scratch? || stage.digest || stage.interpolated_tag?
13
+
14
+ if stage.tag.nil?
15
+ warning at(stage.instructions.first), "FROM #{stage.base} has no tag, so it resolves to :latest", SUGGESTION
16
+ elsif stage.tag == "latest"
17
+ warning at(stage.instructions.first), "FROM #{stage.base} is not pinned; the base image can change between builds", SUGGESTION
18
+ end
19
+ end
20
+
21
+ def built_on_another_stage?(stage)
22
+ document.stages.any? { |other| other.named? && other.name == stage.base }
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # No .dockerignore means the whole working tree is shipped to the builder: .git, test
2
+ # artifacts, node_modules, and whatever else happens to be lying around.
3
+ class Dash::Dockerfile::Rules::MissingDockerignore < Dash::Dockerfile::Rules::Base
4
+ SUGGESTION = "add a .dockerignore covering .git and any build or dependency directories"
5
+
6
+ def findings
7
+ return [] if context_dir.nil? || dockerignore
8
+
9
+ [ warning(Dash::Dockerfile::Dockerignore::FILENAME, "the build context has no .dockerignore, so everything in it is sent to the builder", SUGGESTION) ]
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ # Without a cache mount, an install that misses the layer cache downloads every package
2
+ # again. With one, it reuses whatever the last build left behind.
3
+ class Dash::Dockerfile::Rules::NoCacheMount < Dash::Dockerfile::Rules::Base
4
+ def findings
5
+ document.instructions.filter_map do |instruction|
6
+ next unless installs?(instruction)
7
+ next if Array(instruction.flags["mount"]).any? { |mount| mount.include?("type=cache") }
8
+
9
+ note at(instruction), "#{command_for(instruction)} runs without a cache mount, so a cache miss downloads everything again",
10
+ "add --mount=type=cache,target=#{target_for(instruction)}"
11
+ end
12
+ end
13
+
14
+ private
15
+ def installs?(instruction)
16
+ context.dependency_install?(instruction) || context.apt_install?(instruction)
17
+ end
18
+
19
+ def command_for(instruction)
20
+ context.install_command(instruction) || "apt-get install"
21
+ end
22
+
23
+ def target_for(instruction)
24
+ context.install_cache_target(instruction) || Dash::Dockerfile::Context::APT_INSTALL.last
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ # A final stage that sets no USER runs as whatever its base image left it as — root, for
2
+ # nearly every official image — and so does anything that gets a shell in it.
3
+ class Dash::Dockerfile::Rules::RootUser < Dash::Dockerfile::Rules::Base
4
+ SUGGESTION = "create a non-root user and add a USER line before the entrypoint, unless the base image already switched"
5
+
6
+ def findings
7
+ stage = document.final_stage or return []
8
+ return [] if stage.instructions.any? { |instruction| instruction.name == "USER" }
9
+
10
+ [ note(at(stage.instructions.first), "the final stage sets no USER, so the container runs as whatever the base image does — usually root", SUGGESTION) ]
11
+ end
12
+ end
@@ -0,0 +1,30 @@
1
+ # Build args and ENV values are readable in the image history forever. A secret that has
2
+ # to be present at build time belongs in a mount that leaves no layer behind.
3
+ class Dash::Dockerfile::Rules::SecretInBuildArg < Dash::Dockerfile::Rules::Base
4
+ SECRETISH = /(PASSWORD|SECRET|TOKEN|_KEY)\b/i
5
+ # Rails' own placeholder: it exists precisely so no real key is needed at build time.
6
+ ALLOWED = %w[ SECRET_KEY_BASE_DUMMY ].freeze
7
+ SUGGESTION = "pass it with --mount=type=secret and list it under builder: secrets: in deploy.yml"
8
+
9
+ def findings
10
+ document.instructions.flat_map do |instruction|
11
+ next [] unless %w[ ARG ENV ].include?(instruction.name)
12
+
13
+ names(instruction).filter_map do |name|
14
+ next if ALLOWED.include?(name) || !name.match?(SECRETISH)
15
+
16
+ warning at(instruction), "#{instruction.name} #{name} bakes a secret into the image history", SUGGESTION
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+ # `ENV KEY value` (legacy form) declares exactly one name, whatever the value holds;
23
+ # `ENV A=1 B=2` and `ARG NAME[=default]` declare one per assignment.
24
+ def names(instruction)
25
+ first = instruction.args.split(/\s+/).first.to_s
26
+ return [ first ] unless first.include?("=")
27
+
28
+ instruction.args.scan(/(?:\A|\s)([A-Za-z_]\w*)=/).flatten
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # Compilers and header packages installed in the only stage ship to production: a bigger
2
+ # image, a bigger attack surface, and nothing gained once the gems are built.
3
+ class Dash::Dockerfile::Rules::SingleStageBuildDeps < Dash::Dockerfile::Rules::Base
4
+ # A lookahead for whitespace or a shell separator rather than `\b`: `+` is not a word
5
+ # character, so `g++\b` never matches.
6
+ BUILD_PACKAGES = /\b(build-essential|gcc|g\+\+|make|[\w.+-]+-dev)(?=[\s;&|)]|$)/
7
+ SUGGESTION = "split into a build stage and a runtime stage, and COPY --from the build output"
8
+
9
+ def findings
10
+ return [] unless document.stages.one?
11
+
12
+ document.stages.first.instructions.filter_map do |instruction|
13
+ next unless instruction.name == "RUN"
14
+ next unless (packages = instruction.shell_command.scan(BUILD_PACKAGES).map(&:first)).any?
15
+
16
+ warning at(instruction), "the only stage installs build tooling (#{packages.uniq.join(", ")}), which ships in the image", SUGGESTION
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # Measured only: a dependency install that missed the cache and cost real time, where no
2
+ # broad copy above it explains why. Something else invalidated the layer, and the operator
3
+ # is the only one who can say what.
4
+ class Dash::Dockerfile::Rules::UncachedInstall < Dash::Dockerfile::Rules::Base
5
+ THRESHOLD_SECONDS = 10
6
+ SUGGESTION = "check what changed above it — a COPY, an ARG, or a base image that moved"
7
+
8
+ def findings
9
+ return [] unless build
10
+
11
+ document.instructions.filter_map do |instruction|
12
+ next unless context.dependency_install?(instruction) && !context.busted_by_broad_copy?(instruction)
13
+
14
+ step = context.build_step_for(instruction)
15
+ next if step.nil? || step.cached || step.seconds.to_f <= THRESHOLD_SECONDS
16
+
17
+ note at(instruction), format("`%s` ran %.1fs uncached", context.install_command(instruction), step.seconds), SUGGESTION
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,65 @@
1
+ # One build stage: everything from a FROM up to the next one.
2
+ #
3
+ # `shipped?` is the distinction that matters for advice. A stage only reachable through
4
+ # `COPY --from=` contributes files, not layers, so apt hygiene and a missing USER in it
5
+ # say nothing about the image that ends up on the server. The last stage is shipped, and
6
+ # so is anything it (transitively) builds FROM.
7
+ class Dash::Dockerfile::Stage
8
+ # A registry may carry a port (`localhost:5000/app`), so the tag is only what follows a
9
+ # colon in the last path segment.
10
+ IMAGE_REF = %r{\A(?<image>(?:[^/@\s]+/)*[^:/@\s]+)(?::(?<tag>[^@\s]+))?(?:@(?<digest>\S+))?\z}
11
+ INTERPOLATION = /\$\{?\w+\}?/
12
+ # Docker's reserved empty base: nothing to pin, nothing to resolve.
13
+ SCRATCH = "scratch".freeze
14
+
15
+ attr_reader :name, :index, :base, :instructions
16
+ attr_writer :shipped
17
+
18
+ def initialize(name:, index:, base:, from:, named: true)
19
+ @name = name
20
+ @named = named
21
+ @index = index
22
+ @base = base
23
+ @from = from
24
+ @instructions = [ from ]
25
+ @shipped = false
26
+ end
27
+
28
+ def shipped?
29
+ @shipped
30
+ end
31
+
32
+ # An explicit `AS` name, as opposed to the `stage-N` label dash generates.
33
+ def named?
34
+ @named
35
+ end
36
+
37
+ def scratch?
38
+ base == SCRATCH
39
+ end
40
+
41
+ def image
42
+ ref[:image]
43
+ end
44
+
45
+ def tag
46
+ ref[:tag]
47
+ end
48
+
49
+ def digest
50
+ ref[:digest]
51
+ end
52
+
53
+ # `FROM ruby:$RUBY_VERSION` pins a version through an ARG, so it is not the unpinned
54
+ # base the latest-base rule is looking for. With no tag at all, `FROM $IMAGE` might be
55
+ # carrying one inside the variable — unknowable, so it passes too. An explicit `:latest`
56
+ # is explicit whatever the registry in front of it was.
57
+ def interpolated_tag?
58
+ (tag || image).to_s.match?(INTERPOLATION)
59
+ end
60
+
61
+ private
62
+ def ref
63
+ @ref ||= base.match(IMAGE_REF) || {}
64
+ end
65
+ end
@@ -131,10 +131,11 @@ class Dash::OtelShipper
131
131
 
132
132
  def typed_value(v)
133
133
  case v
134
- when Integer then { intValue: v }
135
- when Float then { doubleValue: v }
136
- when Array then { arrayValue: { values: v.map { |e| typed_value(e) } } }
137
- else { stringValue: v.to_s }
134
+ when true, false then { boolValue: v }
135
+ when Integer then { intValue: v }
136
+ when Float then { doubleValue: v }
137
+ when Array then { arrayValue: { values: v.map { |e| typed_value(e) } } }
138
+ else { stringValue: v.to_s }
138
139
  end
139
140
  end
140
141
 
@@ -43,9 +43,61 @@ class Dash::Output::OtelLogger < Dash::Output::BaseLogger
43
43
  "kamal.command": full_command(payload), "kamal.runtime": runtime,
44
44
  **deployment_attrs(payload, status: "succeeded"))
45
45
  end
46
+ ship_report(payload)
46
47
  puts "Logs sent to #{@endpoint}"
47
48
  end
48
49
 
50
+ # The same numbers the table printed, as events a backend can chart across deploys.
51
+ # Shipping them must never be the thing that fails a deploy that already succeeded,
52
+ # so anything that goes wrong here costs one line on stderr.
53
+ def ship_report(payload)
54
+ report = payload[:report] or return
55
+ attrs = deployment_attrs(payload)
56
+
57
+ report.timings.to_h.each { |phase| @shipper.event("dash.phase", **phase_attrs(phase), **attrs) }
58
+ ship_build(report.build, attrs) if report.build&.any?
59
+ report.advice.each { |finding| @shipper.event("dash.advice", **advice_attrs(finding), **attrs) }
60
+ rescue StandardError => e
61
+ $stderr.puts "OTel report events failed: #{e.class}: #{e.message}"
62
+ $stderr.puts e.backtrace.join("\n") if ENV["VERBOSE"]
63
+ end
64
+
65
+ def ship_build(build, attrs)
66
+ @shipper.event("dash.build", **build_attrs(build), **attrs)
67
+ build.dockerfile_steps.each { |step| @shipper.event("dash.build.step", **step_attrs(step), **attrs) }
68
+ end
69
+
70
+ def phase_attrs(phase)
71
+ {
72
+ "dash.phase.name": phase[:name], "dash.phase.depth": phase[:depth], "dash.phase.seconds": phase[:seconds],
73
+ "dash.phase.detail": phase[:detail], "dash.phase.commands": phase[:commands],
74
+ "dash.phase.command_seconds": phase[:command_seconds], "dash.phase.connect_seconds": phase[:connect_seconds]
75
+ }.compact
76
+ end
77
+
78
+ def build_attrs(build)
79
+ {
80
+ "dash.build.context_bytes": build.context_bytes, "dash.build.context_seconds": build.context_seconds,
81
+ "dash.build.cached_steps": build.cached_steps.size, "dash.build.total_steps": build.dockerfile_steps.size,
82
+ "dash.build.export_seconds": build.export_seconds, "dash.build.cache_export_seconds": build.cache_export_seconds,
83
+ "dash.build.push_seconds": build.push_seconds
84
+ }.compact
85
+ end
86
+
87
+ def step_attrs(step)
88
+ {
89
+ "dash.build.stage": step.stage, "dash.build.ordinal": step.ordinal, "dash.build.instruction": step.instruction,
90
+ "dash.build.seconds": step.seconds, "dash.build.cached": step.cached
91
+ }.compact
92
+ end
93
+
94
+ def advice_attrs(finding)
95
+ {
96
+ "dash.advice.rule": finding.rule, "dash.advice.severity": finding.severity.to_s,
97
+ "dash.advice.location": finding.location, "dash.advice.message": finding.message
98
+ }.compact
99
+ end
100
+
49
101
  def on_close
50
102
  @shipper.shutdown
51
103
  end
@@ -0,0 +1,94 @@
1
+ require "json"
2
+
3
+ # The saved reports for one destination, newest first.
4
+ #
5
+ # Everything here is best-effort by design: the directory is the operator's, a report may
6
+ # be half-written by a deploy that was killed mid-flush, and a future dash may write a
7
+ # schema this one does not know. None of that is worth a word on a deploy, so an
8
+ # unreadable file is skipped rather than reported.
9
+ class Dash::Report::History
10
+ attr_reader :directory, :destination
11
+
12
+ def initialize(directory, destination: nil)
13
+ @directory = directory
14
+ @destination = destination
15
+ end
16
+
17
+ def recent(count)
18
+ documents.first(count)
19
+ end
20
+
21
+ def any?
22
+ documents.any?
23
+ end
24
+
25
+ # Keeps the newest `count` reports of this destination and deletes the rest. Other
26
+ # destinations are left alone: they have their own budget, and a staging deploy must
27
+ # not age out production's history.
28
+ def prune(count)
29
+ entries.drop(count).each do |path, _document|
30
+ File.delete(path)
31
+ rescue SystemCallError
32
+ nil
33
+ end
34
+ end
35
+
36
+ private
37
+ def documents
38
+ entries.map(&:last)
39
+ end
40
+
41
+ # Sorted by filename rather than by the timestamp inside, because the name is what an
42
+ # operator sorts by too — and a report whose body we could not read is not one we can
43
+ # order by its contents. Newest first.
44
+ def entries
45
+ @entries ||= Dir.glob(File.join(directory, "*.json")).sort_by { |path| order_key(path) }.reverse.filter_map { |path| entry_for(path) }
46
+ end
47
+
48
+ # Two runs in the same second are `X.json` and `X-2.json`, and byte for byte the
49
+ # unsuffixed one sorts last — which would make the older run the newest. The suffix
50
+ # is the run order; the command a name ends in is never a number, so a trailing
51
+ # `-N` is only ever ours.
52
+ def order_key(path)
53
+ name = File.basename(path, ".json")
54
+ base, suffix = name.match(/\A(.*)-(\d+)\z/)&.captures
55
+
56
+ base ? [ base, suffix.to_i ] : [ name, 1 ]
57
+ end
58
+
59
+ def entry_for(path)
60
+ document = JSON.parse(File.read(path), symbolize_names: true)
61
+ return unless document.is_a?(Hash) && document[:schema] == Dash::Report::SCHEMA
62
+ return unless document[:destination] == destination
63
+ return unless well_formed?(document)
64
+
65
+ [ path, document ]
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ # Claiming schema 1 is not the same as being one. A hand-edited file that parses but
71
+ # holds the wrong shapes would crash `dash report` somewhere far from the mistake, or
72
+ # — worse — render as an empty table. The top-level fields the writer always sets are
73
+ # checked by shape; then the test that matters for everything nested: render it.
74
+ # Anything that cannot be is skipped with the unreadable files, the raise landing in
75
+ # #entry_for's rescue.
76
+ def well_formed?(document)
77
+ return false unless list_of_hashes?(document[:phases])
78
+ return false unless optional?(document[:advice]) { |advice| list_of_hashes?(advice) }
79
+ return false unless optional?(document[:build]) { |build| build.is_a?(Hash) }
80
+ return false unless optional?(document[:error]) { |error| error.is_a?(Hash) }
81
+ return false unless optional?(document[:runtime]) { |runtime| runtime.is_a?(Numeric) }
82
+
83
+ Dash::Report.from_h(document).lines
84
+ true
85
+ end
86
+
87
+ def optional?(value)
88
+ value.nil? || yield(value)
89
+ end
90
+
91
+ def list_of_hashes?(value)
92
+ value.is_a?(Array) && value.all?(Hash)
93
+ end
94
+ end