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.
- checksums.yaml +4 -4
- data/lib/dash/build/progress_parser.rb +136 -0
- data/lib/dash/build/report.rb +104 -0
- data/lib/dash/build/step.rb +49 -0
- data/lib/dash/cli/app/boot.rb +20 -10
- data/lib/dash/cli/app.rb +2 -2
- data/lib/dash/cli/base.rb +117 -2
- data/lib/dash/cli/build.rb +61 -5
- data/lib/dash/cli/doctor/config_checks.rb +36 -1
- data/lib/dash/cli/doctor.rb +2 -1
- data/lib/dash/cli/main.rb +24 -7
- data/lib/dash/cli/prune.rb +5 -8
- data/lib/dash/cli/report.rb +97 -0
- data/lib/dash/cli/templates/sample_hooks/post-deploy.sample +5 -0
- data/lib/dash/commander.rb +10 -2
- data/lib/dash/commands/app.rb +18 -0
- data/lib/dash/commands/auditor.rb +10 -0
- data/lib/dash/commands/builder/base.rb +18 -0
- data/lib/dash/commands/builder.rb +1 -1
- data/lib/dash/configuration/docs/configuration.yml +6 -0
- data/lib/dash/configuration/docs/report.yml +39 -0
- data/lib/dash/configuration/report.rb +65 -0
- data/lib/dash/configuration.rb +2 -1
- data/lib/dash/dockerfile/analyzer.rb +66 -0
- data/lib/dash/dockerfile/context.rb +147 -0
- data/lib/dash/dockerfile/dockerignore.rb +29 -0
- data/lib/dash/dockerfile/document.rb +28 -0
- data/lib/dash/dockerfile/finding.rb +20 -0
- data/lib/dash/dockerfile/hadolint.rb +75 -0
- data/lib/dash/dockerfile/instruction.rb +58 -0
- data/lib/dash/dockerfile/parser.rb +199 -0
- data/lib/dash/dockerfile/rules/apt_hygiene.rb +35 -0
- data/lib/dash/dockerfile/rules/base.rb +44 -0
- data/lib/dash/dockerfile/rules/cache_busting_arg.rb +40 -0
- data/lib/dash/dockerfile/rules/cache_export_cost.rb +20 -0
- data/lib/dash/dockerfile/rules/context_size.rb +20 -0
- data/lib/dash/dockerfile/rules/copy_before_install.rb +34 -0
- data/lib/dash/dockerfile/rules/curl_pipe_shell.rb +16 -0
- data/lib/dash/dockerfile/rules/dockerignore_gaps.rb +36 -0
- data/lib/dash/dockerfile/rules/inline_env_blob.rb +23 -0
- data/lib/dash/dockerfile/rules/latest_base.rb +24 -0
- data/lib/dash/dockerfile/rules/missing_dockerignore.rb +11 -0
- data/lib/dash/dockerfile/rules/no_cache_mount.rb +26 -0
- data/lib/dash/dockerfile/rules/root_user.rb +12 -0
- data/lib/dash/dockerfile/rules/secret_in_build_arg.rb +30 -0
- data/lib/dash/dockerfile/rules/single_stage_build_deps.rb +19 -0
- data/lib/dash/dockerfile/rules/uncached_install.rb +20 -0
- data/lib/dash/dockerfile/stage.rb +65 -0
- data/lib/dash/otel_shipper.rb +5 -4
- data/lib/dash/output/otel_logger.rb +52 -0
- data/lib/dash/report/history.rb +94 -0
- data/lib/dash/report/trends.rb +129 -0
- data/lib/dash/report/writer.rb +142 -0
- data/lib/dash/report.rb +170 -0
- data/lib/dash/sshkit_with_ext.rb +62 -0
- data/lib/dash/timings.rb +163 -10
- data/lib/dash/utils.rb +7 -0
- data/lib/dash/version.rb +1 -1
- data/lib/dash.rb +4 -0
- 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
|
data/lib/dash/otel_shipper.rb
CHANGED
|
@@ -131,10 +131,11 @@ class Dash::OtelShipper
|
|
|
131
131
|
|
|
132
132
|
def typed_value(v)
|
|
133
133
|
case v
|
|
134
|
-
when
|
|
135
|
-
when
|
|
136
|
-
when
|
|
137
|
-
|
|
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
|