#!/bin/sh # script/assert-context-complete: fail unless the build context a stage # was given contained every source file the repository has. # # Usage: script/assert-context-complete LOG STAGE EXPECTED # # LOG must be `docker build --progress=plain` output. EXPECTED is a list # of repo-relative paths, one per line, as script/repo-source-manifest # prints it. # # script/assert-step-ran answers "did the tool execute". This answers # the other half: "was the tool given the tree". They are different # questions, and a green build can satisfy the first while failing the # second — a .dockerignore entry, or a COPY that brings in less than the # whole tree, removes files from the context silently, and golangci-lint # then genuinely runs, genuinely examines what it was handed, and # genuinely reports `0 issues.` over a repo that has a violation in it. # `go test ./...` likewise never runs a package that did not arrive. # # How it knows: each source-consuming stage emits, right after its # `COPY . .`, an inventory of the Go files actually present — # # context-manifest-begin # context-file: cmd/gotemplate/main.go # ... # context-manifest-end # # — and this compares that against EXPECTED, which comes from the git # index rather than from the same build. The two sources are # independent: .dockerignore decides the first and cannot touch the # second. An expectation read from the evidence would prove nothing. # # The manifest lines are only believed under the same discipline # script/assert-step-ran applies to a tool's success line: they must be # attributed to the id of a step whose header is "#ID [STAGE n/m] CMD" # with CMD matching the manifest command, which BuildKit reported DONE, # and both sentinels must be present. So another step's output does not # count, a header does not count, a cached step (which writes nothing) # does not count, and a truncated log fails rather than passing with a # short list. # # Only one direction is checked: everything the repo has must have # arrived. Files in the context that git does not track are not a # failure — untracked local work is normal and hides nothing. # # What this does not cover: a Dockerfile edit to the manifest step # itself, and evidence forged inside a matched command. Both are visible # in the Dockerfile in plain sight, and both are equally outside # script/assert-step-ran. This is not an exhaustive list of ways a green # run can be untrue; it is the ones known. set -eu die() { echo "script/assert-context-complete: $*" >&2 exit 1 } [ $# -eq 3 ] || die "usage: $0 LOG STAGE EXPECTED" [ -r "$1" ] || die "cannot read $1" [ -r "$3" ] || die "cannot read $3" # The stage and the expected-list path travel in the environment, not in # -v: awk expands escape sequences in a -v assignment. ACC_STAGE="$2" ACC_EXPECTED="$3" awk ' function fail(msg) { printf "script/assert-context-complete: %s\n", msg | "cat 1>&2" close("cat 1>&2") exit 1 } # The package a missing file belongs to — its directory, which is what a # reader needs named. A bare "." for a root-level file reads as a typo # next to the sentence punctuation, so it is spelled out. function pkgof(path, i) { i = length(path) while (i > 0 && substr(path, i, 1) != "/") i-- return i == 0 ? "the repository root" : substr(path, 1, i - 1) } function preview(list, n, limit, i, out) { for (i = 1; i <= n && i <= limit; i++) out = out (i > 1 ? ", " : "") list[i] if (n > limit) out = out sprintf(", and %d more", n - limit) return out } BEGIN { stage = ENVIRON["ACC_STAGE"] # Fixed by the protocol the Dockerfile emits, so callers cannot # drift from it. step = "^RUN echo context-manifest-begin" beginmark = "context-manifest-begin" endmark = "context-manifest-end" prefix = "context-file: " expected_path = ENVIRON["ACC_EXPECTED"] while ((getline line < expected_path) > 0) if (line != "") expected[++nexpected] = line close(expected_path) if (nexpected == 0) fail(sprintf("the expected-file list (%s) is empty, so any build context would satisfy this check", expected_path)) } # Every progress line is "#ID " and then a step header, a status, or one # line the step itself wrote. /^#[0-9]+ / { id = substr($1, 2) rest = substr($0, length($1) + 2) if (rest == "CACHED") { cached[id] = 1; next } if (rest ~ /^DONE /) { done[id] = 1; next } # Header: "[STAGE n/m] CMD", or "[PLATFORM STAGE n/m] CMD" when the # build names a platform. Bracketed spans with no n/m are BuildKit # internals, not steps. if (substr(rest, 1, 1) == "[") { p = index(rest, "] ") if (p == 0) next n = split(substr(rest, 2, p - 2), part, " ") if (n < 2 || part[n] !~ /^[0-9]+\/[0-9]+$/) next if (part[n - 1] != stage) next if (substr(rest, p + 2) ~ step) matched[id] = 1 next } # Output: "#ID 0.31 ". if (rest ~ /^[0-9]+[.][0-9]+ /) { sub(/^[0-9]+[.][0-9]+ /, "", rest) if (rest == beginmark) begun[id] = 1 else if (rest == endmark) ended[id] = 1 else if (index(rest, prefix) == 1) arrived[id SUBSEP substr(rest, length(prefix) + 1)] = 1 } } END { for (id in matched) { nmatched++ if (cached[id]) ncached++ if (done[id] && begun[id] && ended[id]) chosen = id } if (nmatched == 0) fail(sprintf("the build ran no step matching /%s/ in stage \"%s\", so the build context was never inventoried. Either the stage is not in the build graph — renamed, deleted, or nothing the final stage builds depends on it any more — or the manifest step was taken out of it", step, stage)) if (chosen == "" && ncached == nmatched) fail(sprintf("every step matching /%s/ in stage \"%s\" was served from cache, so its inventory describes an earlier tree rather than this one; the cache bust for that stage is not taking effect", step, stage)) if (chosen == "") fail(sprintf("a step matching /%s/ ran in stage \"%s\" but wrote no complete inventory between %s and %s. The log is truncated, the build was not run with --progress=plain, or that step no longer emits the manifest", step, stage, beginmark, endmark)) for (i = 1; i <= nexpected; i++) { if (arrived[chosen SUBSEP expected[i]]) continue missing[++nmissing] = expected[i] pkg = pkgof(expected[i]) if (!(pkg in seenpkg)) { seenpkg[pkg] = 1; pkgs[++npkgs] = pkg } } if (nmissing == 0) exit 0 fail(sprintf("%d of the %d source files this repository tracks never reached the build context of stage \"%s\", so nothing examined them. Missing package(s): %s. Missing file(s): %s. A .dockerignore entry, or a COPY that brings in less than the whole tree, drops files silently: the tool still runs, still finds nothing wrong with what it was handed, and still reports success", nmissing, nexpected, stage, preview(pkgs, npkgs, 10), preview(missing, nmissing, 10))) } ' "$1"