Files
cozystack/hack/go-module-fetch-contract.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

332 lines
14 KiB
Bash

#!/usr/bin/env bats
# Contract for surviving a transient proxy.golang.org failure (an HTTP/2
# stream reset, "stream error ... INTERNAL_ERROR"), which otherwise fails an
# image build or the unit test job outright. Three measures, each pinned here:
#
# 1. GOPROXY ends in "|direct", not Go's default ",direct": after a comma Go
# falls back to direct only on a 404 or 410, after a pipe on any error. Image
# builds get it as a build arg from hack/common-envs.mk, and a build arg
# reaches a stage only when that stage declares it, so every Go builder
# stage must say `ARG GOPROXY`. Direct fetching needs git, which the alpine
# golang images lack, so there the retry below is the only cover. Runner
# jobs that run Go get it as env.
# 2. Every explicit `go mod download` is retried a bounded number of times.
# Runner jobs call hack/go-mod-download.sh. Dockerfiles carry the loop
# inline, in the one form RETRY_LOOP below pins: their build contexts
# differ, so no single script can be copied in, and a named build context
# passed through the shared BUILDX_ARGS makes buildx refuse every Dockerfile
# pinned to a frontend older than dockerfile:1.4.
# 3. The unit test job restores the module cache, and the main-branch warmer
# writes it under the same key, so a PR's first run starts warm.
#
# Written for POSIX sh: hack/cozytest.sh sources this file into /bin/sh.
#
# Requires: awk, make, sed.
load test_helper
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
RETRY="$REPO_ROOT/hack/go-mod-download.sh"
WORKFLOWS="$REPO_ROOT/.github/workflows"
GOPROXY_ENV='GOPROXY: "https://proxy.golang.org|direct"'
RETRY_LOOP='^RUN for attempt in 1 2 3; do ([A-Z_]+=[^ ]+ )*go mod download && break; \[ "\$attempt" = 3 \] && exit 1; sleep \$\(\(attempt \* 10\)\); done$'
# Runner jobs that run Go without a module cache by decision: release jobs,
# run a handful of times per release, get GOPROXY only.
NO_CACHE="tags.yaml:build-amd64
promote-rc.yaml:promote"
dockerfiles() {
find "$REPO_ROOT"/packages -path '*/charts' -prune -o \( -name Dockerfile -o -name Containerfile \) -type f -print | sort
}
# One line per Go builder stage: "<declares ARG GOPROXY 0|1> <FROM line>". A
# stage is a Go builder when its base is a golang image, an ARG defaulting to
# one, or an earlier Go builder stage.
go_stages() {
awk '
function flush() { if (from != "" && gostage) printf "%d %s\n", declared, from }
/^ARG[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=/ && from == "" {
kv = $2; eq = index(kv, "="); argdefault[substr(kv, 1, eq - 1)] = substr(kv, eq + 1)
}
/^FROM[[:space:]]/ {
flush(); from = $0; declared = 0; gostage = 0; name = ""; base = ""
for (i = 2; i <= NF; i++) {
if ($i !~ /^--/ && base == "") base = $i
else if (tolower($i) == "as" && i < NF) name = $(i + 1)
}
ref = base; gsub(/^\$\{?|\}$/, "", ref)
if (base ~ /^\$/ && ref in argdefault) base = argdefault[ref]
if (base ~ /(^|\/)golang[:@]/ || base in gonames) gostage = 1
if (gostage && name != "") gonames[name] = 1
next
}
/^ARG[[:space:]]+GOPROXY([[:space:]]|$)/ { declared = 1 }
END { flush() }
' "$1"
}
# One line per alpine golang stage that runs `go build` or `go install` with
# neither git nor a prior `go mod download`: the `|direct` fallback needs git,
# so such a stage has no cover at all when the proxy drops a stream mid-build.
# `go install pkg@version` resolves outside the stage's module, so a download
# does not cover it and only git does.
uncovered_alpine_builds() {
sed -e ':a' -e '/\\$/N; s/\\\n//; ta' "$1" | awk '
function flush() { if (alpine && !git && ((build && !download) || install)) print from }
/^FROM[[:space:]]/ { flush(); from = $0; alpine = ($0 ~ /golang:[^ ]*-alpine/); build = 0; install = 0; git = 0; download = 0; next }
/^RUN[[:space:]]/ && /apk add[^&;]*[[:space:]]git([[:space:]]|$)/ { git = 1 }
/^RUN[[:space:]]/ && /go mod download/ { download = 1 }
/^RUN[[:space:]]/ && /go install[^&;|]*@/ { install = 1 }
/^RUN[[:space:]]/ && /go (build|install)/ && !download { build = 1 }
END { flush() }
'
}
# RUN instructions with continuation lines joined, one per line.
run_lines() {
sed -e ':a' -e '/\\$/N; s/\\\n//; ta' "$1" | grep '^RUN[[:space:]]' || true
}
# Lines of one job's block, comment lines dropped so a commented-out step never
# satisfies a pin.
job_block() {
awk -v job=" $1:" '
$0 == job { inside = 1; next }
/^ [a-zA-Z0-9_-]+:$/ { inside = 0 }
inside' "$2" | grep -v '^[[:space:]]*#' || true
}
job_names() {
awk '/^jobs:$/ { j = 1; next } j && /^[^ ]/ { j = 0 } j && /^ [a-zA-Z0-9_-]+:$/ { sub(/:$/, ""); sub(/^ /, ""); print }' "$1"
}
# A job runs Go on the runner when it sets Go up, calls the go tool, or runs a
# root make target that does. A pattern rather than a helper function:
# hack/cozytest.sh appends `return 0` to every line that is exactly `}`, so a
# helper's exit status never reaches the caller there.
GO_JOB_RE='actions/setup-go@|(^|[^A-Za-z0-9_-])go (build|test|run|mod|generate|install)[[:space:]]|(^|[^A-Za-z0-9_-])make[^#]*[[:space:]](unit-tests|test-controllers|assets|assets-cozypkg|openapi-json|generate)([[:space:]]|$)'
@test "every Go builder stage declares ARG GOPROXY" {
failed=""
checked=0
stagelist="$(mktemp)"
for f in $(dockerfiles); do
go_stages "$f" >"$stagelist"
while read -r declared from; do
checked=$((checked + 1))
[ "$declared" = 1 ] || failed="$failed
${f#"$REPO_ROOT"/}: $from"
done <"$stagelist"
done
rm -f "$stagelist"
[ "$checked" -gt 0 ]
if [ -n "$failed" ]; then
echo "Go builder stages that never receive the GOPROXY build arg:$failed" >&2
exit 1
fi
}
@test "a Go stage built FROM an ARG or an earlier Go stage is still a Go stage" {
fixture="$(mktemp)"
printf '%s\n' 'ARG builder_image=docker.io/library/golang:1.27' 'FROM ${builder_image} AS a' 'ARG GOPROXY' 'FROM a AS b' 'FROM alpine' >"$fixture"
out="$(go_stages "$fixture")"
rm -f "$fixture"
[ "$out" = '1 FROM ${builder_image} AS a
0 FROM a AS b' ]
}
@test "image builds pass the pipe GOPROXY through BUILDX_ARGS" {
out="$(make --no-print-directory -s -n -C "$REPO_ROOT/packages/system/cozystack-controller" image COZYSTACK_VERSION=0.0.0)"
echo "$out" | grep -qF -- "--build-arg 'GOPROXY=https://proxy.golang.org|direct'"
}
@test "a host GOPROXY does not reach image builds, IMAGE_GOPROXY does" {
# A host mirror on localhost or a file:// proxy is unreachable from inside
# buildkit, so the host's GOPROXY must not leak into the build.
dir="$REPO_ROOT/packages/system/cozystack-controller"
out="$(GOPROXY=http://localhost:3000 make --no-print-directory -s -n -C "$dir" image COZYSTACK_VERSION=0.0.0)"
echo "$out" | grep -qF -- "--build-arg 'GOPROXY=https://proxy.golang.org|direct'"
out="$(IMAGE_GOPROXY=https://mirror.example.com make --no-print-directory -s -n -C "$dir" image COZYSTACK_VERSION=0.0.0)"
echo "$out" | grep -qF -- "--build-arg 'GOPROXY=https://mirror.example.com'"
}
@test "the shared buildx args carry no named build context" {
# metallb is one of the images pinned to dockerfile:1.2, which buildx refuses
# to build at all once --build-context is on the command line.
out="$(make --no-print-directory -s -n -C "$REPO_ROOT/packages/system/metallb" image COZYSTACK_VERSION=0.0.0)"
echo "$out" | grep -q 'docker buildx build'
if echo "$out" | grep -q -- '--build-context'; then
echo "BUILDX_ARGS reaches dockerfile:1.2 images with a named build context" >&2
false
fi
}
@test "every go mod download in a Dockerfile runs inside the bounded retry loop" {
failed=""
checked=0
runlist="$(mktemp)"
for f in $(dockerfiles); do
run_lines "$f" | grep 'go mod download' >"$runlist" || true
while IFS= read -r line; do
checked=$((checked + 1))
printf '%s\n' "$line" | grep -qE -- "$RETRY_LOOP" || failed="$failed
${f#"$REPO_ROOT"/}: $line"
done <"$runlist"
done
rm -f "$runlist"
[ "$checked" -gt 0 ]
if [ -n "$failed" ]; then
echo "go mod download outside the bounded retry loop:$failed" >&2
exit 1
fi
}
@test "every alpine golang stage downloads its modules before go build" {
failed=""
for f in $(dockerfiles); do
out="$(uncovered_alpine_builds "$f")"
[ -z "$out" ] || failed="$failed
${f#"$REPO_ROOT"/}: $out"
done
if [ -n "$failed" ]; then
echo "alpine stages where a proxy error mid-build has neither fallback nor retry:$failed" >&2
exit 1
fi
}
@test "an alpine stage is covered by git or by a download before the build" {
fixture="$(mktemp)"
printf '%s\n' 'FROM golang:1.27-alpine AS a' 'RUN go build .' \
'FROM golang:1.27-alpine AS b' 'RUN apk add --no-cache make git' 'RUN go build .' \
'FROM golang:1.27-alpine AS c' 'RUN go mod download' 'RUN go build .' \
'FROM golang:1.27 AS d' 'RUN go build .' \
'FROM golang:1.27-alpine AS e' 'RUN go mod download' 'RUN go install example.com/tool@v1.0.0' \
'FROM golang:1.27-alpine AS f' 'RUN apk add --no-cache git' 'RUN go install example.com/tool@v1.0.0' \
'FROM golang:1.27-alpine AS g' 'RUN go install ./cmd/tool' \
'FROM golang:1.27-alpine AS h' 'RUN go mod download' 'RUN go install ./cmd/tool' >"$fixture"
out="$(uncovered_alpine_builds "$fixture")"
rm -f "$fixture"
[ "$out" = 'FROM golang:1.27-alpine AS a
FROM golang:1.27-alpine AS e
FROM golang:1.27-alpine AS g' ]
}
@test "the retry loop pattern accepts the loop and rejects a bare or unbounded download" {
printf '%s\n' 'RUN for attempt in 1 2 3; do GOOS=$TARGETOS go mod download && break; [ "$attempt" = 3 ] && exit 1; sleep $((attempt * 10)); done' | grep -qE -- "$RETRY_LOOP"
for bad in 'RUN go mod download' \
'RUN for attempt in 1 2 3; do go mod download && break; sleep $((attempt * 10)); done' \
'RUN for attempt in 1 2 3; do go mod download && break; [ "$attempt" = 3 ] && exit 1; sleep $((attempt * 10)); done && go mod download'; do
if printf '%s\n' "$bad" | grep -qE -- "$RETRY_LOOP"; then
echo "pattern accepts: $bad" >&2
false
fi
done
}
@test "the retry script succeeds once go mod download does, within its attempts" {
work="$(mktemp -d)"
printf '%s\n' '#!/bin/sh' 'n=$(cat "$CALLS" 2>/dev/null || echo 0); n=$((n + 1)); echo "$n" >"$CALLS"' '[ "$n" -ge "$SUCCEED_ON" ]' >"$work/go"
chmod +x "$work/go"
rc=0
PATH="$work:$PATH" CALLS="$work/calls" SUCCEED_ON=3 GO_MOD_DOWNLOAD_BACKOFF=0 sh "$RETRY" >/dev/null 2>&1 || rc=$?
calls="$(cat "$work/calls")"
rm -rf "$work"
[ "$rc" -eq 0 ]
[ "$calls" -eq 3 ]
}
@test "the retry script gives up after its attempts and fails" {
work="$(mktemp -d)"
printf '%s\n' '#!/bin/sh' 'n=$(cat "$CALLS" 2>/dev/null || echo 0); echo $((n + 1)) >"$CALLS"' 'exit 1' >"$work/go"
chmod +x "$work/go"
rc=0
PATH="$work:$PATH" CALLS="$work/calls" GO_MOD_DOWNLOAD_BACKOFF=0 sh "$RETRY" >/dev/null 2>&1 || rc=$?
calls="$(cat "$work/calls")"
rm -rf "$work"
[ "$rc" -ne 0 ]
[ "$calls" -eq 3 ]
}
@test "the retry script refuses a non-numeric backoff instead of looping on it" {
work="$(mktemp -d)"
printf '%s\n' '#!/bin/sh' 'echo x >>"$CALLS"' 'exit 1' >"$work/go"
chmod +x "$work/go"
rc=0
PATH="$work:$PATH" CALLS="$work/calls" GO_MOD_DOWNLOAD_BACKOFF=soon sh "$RETRY" >/dev/null 2>&1 || rc=$?
called=0
[ -f "$work/calls" ] && called=1
rm -rf "$work"
[ "$rc" -ne 0 ]
[ "$called" -eq 0 ]
}
@test "every runner job that runs Go sets the pipe GOPROXY" {
failed=""
checked=0
for wf in "$WORKFLOWS"/*.yaml "$WORKFLOWS"/*.yml; do
for job in $(job_names "$wf"); do
block="$(job_block "$job" "$wf")"
echo "$block" | grep -qE -- "$GO_JOB_RE" || continue
checked=$((checked + 1))
echo "$block" | grep -qF -- "$GOPROXY_ENV" || failed="$failed $(basename "$wf"):$job"
done
done
[ "$checked" -gt 0 ]
if [ -n "$failed" ]; then
echo "jobs running Go without GOPROXY=https://proxy.golang.org|direct:$failed" >&2
exit 1
fi
}
@test "every runner job that runs Go without setup-go restores the module cache" {
failed=""
checked=0
for wf in "$WORKFLOWS"/*.yaml "$WORKFLOWS"/*.yml; do
for job in $(job_names "$wf"); do
block="$(job_block "$job" "$wf")"
echo "$block" | grep -qE -- "$GO_JOB_RE" || continue
echo "$block" | grep -q 'actions/setup-go@' && continue
case "
$NO_CACHE
" in *"
$(basename "$wf"):$job
"*) continue ;; esac
checked=$((checked + 1))
echo "$block" | grep -q 'uses: actions/cache@' || failed="$failed $(basename "$wf"):$job"
done
done
[ "$checked" -gt 0 ]
if [ -n "$failed" ]; then
echo "jobs running Go with neither setup-go nor a module cache:$failed" >&2
exit 1
fi
}
# The distinct go-mod cache keys one job uses, so a restore and a save that
# disagree show up as two lines.
go_mod_key() {
job_block "$1" "$WORKFLOWS/$2" | grep -E '^[[:space:]]+key: go-mod-' | sed -e 's/^[[:space:]]*key: //' | sort -u
}
@test "the unit test job and the main warmer share one module cache key over every go.sum" {
checks="$(go_mod_key checks pull-requests.yaml)"
warm="$(go_mod_key warm-cache build-main.yaml)"
[ -n "$checks" ]
[ "$checks" = "go-mod-\${{ runner.os }}-\${{ hashFiles('**/go.sum') }}" ]
[ "$warm" = "$checks" ]
}
@test "the main warmer saves the module cache and both jobs prefetch every module through the retry" {
job_block warm-cache "$WORKFLOWS/build-main.yaml" | grep -q 'uses: actions/cache/save@'
if job_block warm-cache "$WORKFLOWS/build-main.yaml" | grep -q 'restore-keys:'; then
echo "the warmer must not seed a new key from a partial match" >&2
false
fi
for pair in pull-requests.yaml:checks build-main.yaml:warm-cache; do
block="$(job_block "${pair#*:}" "$WORKFLOWS/${pair%%:*}")"
echo "$block" | grep -qF "git ls-files go.mod '*/go.mod'"
echo "$block" | grep -qF 'hack/go-mod-download.sh'
done
}