#!/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 <the line the step wrote>".
    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"
