Files
cozystack/hack/nightly-mirror_test.bats
T
Myasnikov Daniil 9bd8c17971 fix(ci): shape-filter the promote postcondition and close test gaps
Review of the previous commit found the postcondition itself could abort
a release. It grepped the bare "X.Y.Z-rc.N" substring across the tree, and
three live files carry such strings without being image refs:
kubevirt-csi-driver's go.sum pins github.com/golang/protobuf v1.4.0-rc.2
-- a version cozystack actually cut, so promoting v1.4.0 would have
failed on a Go checksum line -- metallb_test.yaml names v1.5.0-rc.2 in a
comment, and the console pnpm-lock.yaml carries rolldown@1.0.0-rc.15. The
remediation the error message gave would have made things worse: declaring
go.sum as a ref source would sed a checksum file.

Match the version only in image-reference position now: after an
image/repository/tag key with no intervening '#', or immediately before an
@sha256 digest. Narrowing it to "${RC}@sha256:" alone would have been
wrong -- cilium keeps `tag: v1.5.0` and `digest:` under separate keys, so
the version is not always adjacent to the digest. Verified against four
real rc versions that previously aborted.

The suite also only tested the fail-closed direction, which is why that
defect survived, and the collector had no completeness guard at all: a
first-party ref added at an unenumerated path on a `latest@` tag was
mirrored by nothing and passed all eighteen tests. Since the mirror's
failure mode is the worst of the three -- the published nightly points at
the private build registry -- that gap mattered most where coverage was
thinnest.

Added: a fail-open test using the three real rc versions above; a
collector completeness diff that discovers refs by content and fails on
any the enumeration misses; coverage for the OCI-artifact shape, whose
deletion previously left every suite green; and an unreadable-file test,
since a read error was being treated as "no match" and silently skipped.

The component-versioned test was vacuous -- its rc version shared no
substring with the kamaji tag it claimed to protect, so it passed by
construction. It now uses an rc version sharing kamaji's 0.19.0 prefix,
which a loosely-anchored implementation would corrupt.

Round-trip fixture discovery matched a combined 'ghcr.io/cozystack/
cozystack/' prefix, which skips keycloak-operator (host in a sibling
registry: key) and kubeovn (host in global.registry.address). It now
matches 'cozystack/cozystack' and stamps split `tag:` keys too.

Also documented two pre-existing gaps rather than half-fixing them: the
nightly host rewrite is a contiguous-substring replace and cannot reach a
split host, and a ref inside a gzip is invisible to every tool including
the postcondition, which skips binaries. Both are host-dimension defects;
the version dimension this change owns is unaffected, because the version
always lives in a tag key regardless of where the host sits.

Assisted-By: Claude <noreply@anthropic.com>
Assisted-By: GPT-5 <noreply@openai.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-21 20:42:28 +05:00

249 lines
13 KiB
Bash

#!/usr/bin/env bats
# Tests for hack/nightly-mirror.sh — the OCIR->GHCR nightly image-mirror selector.
#
# Guards the ref selection and host rewrite: only cozystack-owned component
# images are copied (third-party images and bare upstream tags are skipped, as
# they live in registries this job cannot push to), the cozystack-packages
# artifact is deliberately excluded (it is rebuilt downstream from the rewritten
# tree), and every copy targets the destination registry with the source digest
# preserved.
#
# Harness note: the CI path is hack/cozytest.sh, NOT real bats — see the same
# note in hack/promote-retag_test.bats. No `run`, `$status`, `$output`, `skip`,
# or setup()/teardown(); each @test is a shell function under `set -eu -x`, so a
# non-zero exit aborts the test (that is the exit-0 assertion). A test that
# expects a non-zero exit must capture it with `|| rc=$?`. mikefarah yq is
# assumed present (provided by the test toolchain).
#
# Run with: hack/cozytest.sh hack/nightly-mirror_test.bats
# Build a synthetic baked tree exercising all four image-ref shapes plus the
# refs that MUST be filtered (third-party host, cozystack-packages artifact).
_make_tree() {
D="$(printf 'a%.0s' $(seq 1 64))" # 64-hex fake digest body
t="$1"
mkdir -p "$t/system/foo" "$t/system/bar" "$t/system/split" "$t/system/third" \
"$t/system/splithost" "$t/system/digesthost" "$t/system/globalreg" \
"$t/system/guarded" "$t/core/installer" \
"$t/system/tagfile/images" "$t/system/multus/templates"
# storage shape 2: a plain images/*.tag file, read by templates via
# .Files.Get rather than through values. Invisible to the depth-2 values.yaml
# glob this script used to scan, so it was neither mirrored NOR host-rewritten
# — leaving the published nightly tree pointing at the private build registry.
printf 'iad.ocir.io/idyksih5sir9/cozystack/tagfile:main@sha256:%s\n' "$D" \
> "$t/system/tagfile/images/thing.tag"
# storage shape 3: a ref sed'd straight into a vendored upstream manifest.
# The path must match IMAGE_REF_EXTRA_FILES in hack/lib/image-refs.sh.
printf ' image: iad.ocir.io/idyksih5sir9/cozystack/multus-cni:main@sha256:%s\n' "$D" \
> "$t/system/multus/templates/multus-daemonset-thick.yml"
# shape 1: single string, cozystack-owned -> copied
printf 'image: iad.ocir.io/idyksih5sir9/cozystack/foo:main@sha256:%s\n' "$D" > "$t/system/foo/values.yaml"
# shape 2: split map, cozystack-owned -> copied
{
echo 'image:'
echo ' repository: iad.ocir.io/idyksih5sir9/cozystack/bar'
echo ' tag: main'
printf ' digest: sha256:%s\n' "$D"
} > "$t/system/bar/values.yaml"
# shape 3: split map with the digest embedded in `tag`, cozystack-owned -> copied.
# This is what most package Makefiles write (a single yq call setting .image.tag
# to "$(IMAGE_TAG)@$(digest)"), so it is the dominant real-world shape — linstor,
# kamaji, kilo, metallb and redis-operator all use it. It matches neither shape 1
# (rule 1 sees only the repository-less tag value, which ref_repo() reduces to the
# tag) nor shape 2 (no `digest` key), so until it was matched explicitly these
# images were silently never mirrored while the host rewrite still repointed them
# at the dest registry — a dangling ref that 404s at pull time.
{
echo 'image:'
echo ' repository: iad.ocir.io/idyksih5sir9/cozystack/split'
printf ' tag: main@sha256:%s\n' "$D"
} > "$t/system/split/values.yaml"
# third-party single string -> skipped, and a NUMERIC tag in the same file as a
# cozystack-owned shape-3 ref. `tag: 123` is ordinary YAML, but yq's test()
# aborts on a non-string ("cannot match with !!int"), and collect_refs swallows
# stderr and status — so without a type guard this abort discards the shape-3
# ref alongside it and `numeric` is silently never mirrored. Co-locating the two
# in one file is the point: the failure is per-file, not per-value.
{
printf 'image: docker.io/clastix/kubectl:1.0@sha256:%s\n' "$D"
echo 'vendored:'
echo ' image:'
echo ' repository: docker.io/vendor/thing'
echo ' tag: 123'
echo 'ours:'
echo ' image:'
echo ' repository: iad.ocir.io/idyksih5sir9/cozystack/numeric'
printf ' tag: main@sha256:%s\n' "$D"
} > "$t/system/third/values.yaml"
# shapes 2 and 3 with the host split into a sibling `registry` key instead of
# living inside `repository` — the layout keycloak-operator ships. Rejoining
# the two is what keeps the ref recognisable: emitting the bare `repository`
# yields a host-less ref that the SRC_REGISTRY filter discards as third-party,
# the same silent drop shape 3 exists to fix. Worse here than in promote-retag,
# because the closing host rewrite matches the literal "$SRC_REGISTRY/" string
# and a split-out host does not contain it: the ref would be neither copied nor
# rewritten, publishing a tree that points at the private CI registry.
{
echo 'image:'
echo ' registry: iad.ocir.io'
echo ' repository: idyksih5sir9/cozystack/splithost'
printf ' tag: main@sha256:%s\n' "$D"
} > "$t/system/splithost/values.yaml"
{
echo 'image:'
echo ' registry: iad.ocir.io'
echo ' repository: idyksih5sir9/cozystack/digesthost'
echo ' tag: main'
printf ' digest: sha256:%s\n' "$D"
} > "$t/system/digesthost/values.yaml"
# shape 4: the host is a document-level key (global.registry.address) and the
# image map carries only a bare repository — kube-ovn's wrapper chart, whose
# own `make image` in cozystack/kubeovn-chart writes exactly this layout. The
# co-located `other` block is a chart-level image with no digest: it must not
# be emitted at all, proving the rule filters within global.images rather than
# blanket-prefixing every entry with the registry address.
{
echo 'global:'
echo ' registry:'
echo ' address: iad.ocir.io/idyksih5sir9/cozystack'
echo ' images:'
echo ' globalreg:'
echo ' repository: globalreg'
printf ' tag: v1.2.3@sha256:%s\n' "$D"
echo ' other:'
echo ' repository: other'
echo ' tag: v1.2.3'
} > "$t/system/globalreg/values.yaml"
# A map-typed `repository` co-located with an owned ref. This is the one
# non-string type that actually breaks the concat rules: yq coerces int, bool,
# null and seq on `+`, but a !!map raises "!!str () cannot be added to a
# !!map", and because collect_refs swallows stderr and status that abort would
# take every ref in this file with it. Same per-file blast radius as the
# numeric-tag case above, different trigger — so `survivor` must still be
# mirrored.
{
echo 'broken:'
echo ' image:'
echo ' repository:'
echo ' nested: oops'
printf ' tag: main@sha256:%s\n' "$D"
echo 'ours:'
echo ' image:'
echo ' repository: iad.ocir.io/idyksih5sir9/cozystack/survivor'
printf ' tag: main@sha256:%s\n' "$D"
} > "$t/system/guarded/values.yaml"
# shape 5: operator (cozystack-owned -> copied) + platformSource (cozystack-packages -> skipped)
{
echo 'cozystackOperator:'
printf ' image: iad.ocir.io/idyksih5sir9/cozystack/cozystack-operator:main@sha256:%s\n' "$D"
echo ' platformSourceUrl: oci://iad.ocir.io/idyksih5sir9/cozystack/cozystack-packages'
printf ' platformSourceRef: "digest=sha256:%s"\n' "$D"
} > "$t/core/installer/values.yaml"
}
@test "dry-run mirrors only cozystack-owned component images to the dest registry" {
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
_make_tree "$tmp/tree"
rc=0
hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree" --dry-run \
>"$tmp/out" 2>"$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "nightly-mirror.sh exited $rc; yq: $(yq --version 2>&1)" >&2
echo "--- stderr ---" >&2; cat "$tmp/err" >&2
echo "--- stdout ---" >&2; cat "$tmp/out" >&2
return "$rc"
fi
# The four cozystack-owned component images are each copied to GHCR by digest.
grep -q 'docker://iad.ocir.io/idyksih5sir9/cozystack/foo@sha256:.* docker://ghcr.io/cozystack/cozystack/foo:0.0.0-nightly.test' "$tmp/out"
grep -q 'docker://ghcr.io/cozystack/cozystack/bar:0.0.0-nightly.test' "$tmp/out"
grep -q 'docker://ghcr.io/cozystack/cozystack/cozystack-operator:0.0.0-nightly.test' "$tmp/out"
# shape 3 — the source ref must carry the REPOSITORY, not the bare tag: a rule
# that matched the tag alone would plan a copy from "main@sha256:..." and be
# dropped as non-SRC_REGISTRY, so assert the full source ref, not just the dest.
grep -q 'docker://iad.ocir.io/idyksih5sir9/cozystack/split@sha256:.* docker://ghcr.io/cozystack/cozystack/split:0.0.0-nightly.test' "$tmp/out"
# A shape-3 ref sharing a file with a non-string tag survives — see _make_tree.
grep -q 'docker://ghcr.io/cozystack/cozystack/numeric:0.0.0-nightly.test' "$tmp/out"
# Split-out host (`registry` sibling / global.registry.address). Assert the
# full source ref for the same reason shape 3 does: a rule that dropped the
# host would plan a copy from a host-less repository, which is filtered out
# rather than reported, so checking only the destination proves nothing.
grep -q 'docker://iad.ocir.io/idyksih5sir9/cozystack/splithost@sha256:.* docker://ghcr.io/cozystack/cozystack/splithost:0.0.0-nightly.test' "$tmp/out"
grep -q 'docker://iad.ocir.io/idyksih5sir9/cozystack/digesthost@sha256:.* docker://ghcr.io/cozystack/cozystack/digesthost:0.0.0-nightly.test' "$tmp/out"
grep -q 'docker://iad.ocir.io/idyksih5sir9/cozystack/globalreg@sha256:.* docker://ghcr.io/cozystack/cozystack/globalreg:0.0.0-nightly.test' "$tmp/out"
# The digest-less sibling under global.images is not invented into a ref.
! grep -q '/other' "$tmp/out"
# An owned ref sharing a file with a map-typed `repository` survives.
grep -q 'docker://ghcr.io/cozystack/cozystack/survivor:0.0.0-nightly.test' "$tmp/out"
# A floating tag is moved alongside the pinned version.
grep -q 'docker://ghcr.io/cozystack/cozystack/foo:nightly' "$tmp/out"
# Third-party images never appear in the copy plan.
! grep -q 'docker.io/clastix' "$tmp/out"
! grep -q 'docker.io/vendor' "$tmp/out"
# The cozystack-packages artifact is excluded — it is rebuilt downstream.
! grep -qE 'skopeo copy.*cozystack-packages' "$tmp/out"
# The host rewrite is planned source->dest.
grep -q "s|iad.ocir.io/idyksih5sir9/cozystack/|ghcr.io/cozystack/cozystack/|g" "$tmp/out"
}
@test "empty selection (wrong source registry) exits non-zero with a diagnostic" {
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
_make_tree "$tmp/tree"
# No images live under example.com/nope, so nothing is selected and the script
# exits non-zero rather than silently mirroring an empty set.
rc=0
SRC_REGISTRY="example.com/nope" hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree" --dry-run \
>"$tmp/out" 2>"$tmp/err" || rc=$?
[ "$rc" -ne 0 ]
grep -q 'No cozystack-owned digest-pinned image refs found' "$tmp/err"
}
@test "mirrors refs stored in .tag files and declared templates" {
# A miss here is worse than the equivalent miss in promote-retag: the host
# rewrite walks the same file list as the collection, so an uncollected ref is
# also unrewritten and the published tree keeps pointing at the private build
# registry. Both shapes below were invisible while this scanned the depth-2
# values.yaml alone.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
_make_tree "$tmp/tree"
hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree" --dry-run \
>"$tmp/out" 2>"$tmp/err"
grep -q 'docker://ghcr.io/cozystack/cozystack/tagfile:0.0.0-nightly.test' "$tmp/out"
grep -q 'docker://ghcr.io/cozystack/cozystack/multus-cni:0.0.0-nightly.test' "$tmp/out"
}
@test "the host rewrite and the mirror walk the same file list" {
# The two sets must be equal. A file rewritten but not mirrored leaves a
# dangling ref to an image never pushed to the dest registry; a file mirrored
# but not rewritten leaves the private host in the published tree. Both
# derive from image_ref_files now, so assert it reaches all three shapes.
#
# Scope, so the name does not overclaim: this pins WHICH FILES the rewrite
# visits, not that every ref inside them is successfully rewritten. The sed
# itself only runs outside --dry-run (it needs skopeo) and is a literal
# "<src>/" substring replace, so it still cannot rewrite a host split into a
# sibling `registry:` key (keycloak-operator) or one without a trailing slash
# (kubeovn's global.registry.address). That is a known gap recorded in
# docs/agents/image-refs.md, not something this test covers.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
_make_tree "$tmp/tree"
. hack/lib/image-refs.sh
files=$(image_ref_files "$tmp/tree")
echo "$files" | grep -q '/system/foo/values.yaml$'
echo "$files" | grep -q '/system/tagfile/images/thing.tag$'
echo "$files" | grep -q '/system/multus/templates/multus-daemonset-thick.yml$'
}