Files
cozystack/hack/nightly-mirror_test.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

395 lines
19 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.
#
# The compatibility pass uses cozytest's POSIX translator, so expected errors
# are captured explicitly without Bats-only run/status helpers.
# Test-level EXIT traps hide Bats' failing TAP results; cleanup follows assertions.
#
# Run with: bats hack/nightly-mirror_test.bats
load test_helper
# 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 shipped. 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"
}
# _stub_registry <bin-dir> — skopeo and sha256sum stubs for a registry whose
# dest tags hold a multi-arch index. `inspect --raw` returns the index bytes
# ($MOCK_RAW, default "index-manifest"), which sha256sum maps to $D, the digest
# the tree pins. A plain `inspect` reports a different digest, the one skopeo
# picks for the host platform's child manifest, so a verification that reads
# it instead of hashing the raw index fails. Call after _make_tree sets $D.
_stub_registry() {
mkdir -p "$1"
{
echo '#!/bin/sh'
echo 'case "$1" in'
echo ' inspect)'
echo ' for a in "$@"; do [ "$a" = --raw ] && { printf "%s" "${MOCK_RAW:-index-manifest}"; exit 0; }; done'
printf ' echo "sha256:%s" ;;\n' "$(printf 'b%.0s' $(seq 1 64))"
echo ' *) exit 0 ;;'
echo 'esac'
} > "$1/skopeo"
{
echo '#!/bin/sh'
printf '[ "$(cat)" = index-manifest ] && echo "%s -" || echo "%s -"\n' "$D" "$(printf 'c%.0s' $(seq 1 64))"
} > "$1/sha256sum"
chmod +x "$1/skopeo" "$1/sha256sum"
}
@test "dry-run mirrors only cozystack-owned component images to the dest registry" {
tmp=$(mktemp -d)
_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.
if grep -q '/other' "$tmp/out"; then echo "FAIL: a digest-less entry must not be invented into a ref"; false; fi
# 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.
if grep -q 'docker.io/clastix' "$tmp/out"; then echo "FAIL: a third-party image must not be mirrored"; false; fi
if grep -q 'docker.io/vendor' "$tmp/out"; then echo "FAIL: a third-party image must not be mirrored"; false; fi
# The cozystack-packages artifact is excluded — it is rebuilt downstream.
if grep -qE 'skopeo copy.*cozystack-packages' "$tmp/out"; then echo "FAIL: the packages artifact must not be mirrored"; false; fi
# The host rewrite is planned source->dest, reported by intent for both
# expressions the real path applies: the '<host>/' prefix and the bare-host
# scalar. The message must not name `sed -i` -- the path no longer uses it.
grep -Fq "rewrite image host iad.ocir.io/idyksih5sir9/cozystack/ -> ghcr.io/cozystack/cozystack/" "$tmp/out"
grep -Fq "bare iad.ocir.io/idyksih5sir9/cozystack host scalar -> ghcr.io/cozystack/cozystack" "$tmp/out"
if grep -q 'sed -i' "$tmp/out"; then
echo "FAIL: dry-run still names sed -i" >&2
false
fi
rm -rf "$tmp"
}
@test "empty selection (wrong source registry) exits non-zero with a diagnostic" {
tmp=$(mktemp -d)
_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"
rm -rf "$tmp"
}
@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)
_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"
rm -rf "$tmp"
}
@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); the whole-value
# shape (kubeovn) and keycloak-operator's empty `registry` are covered by
# the dedicated tests below.
tmp=$(mktemp -d)
_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$'
rm -rf "$tmp"
}
@test "the host rewrite reaches a host that is the whole scalar value" {
# kubeovn keeps its host in global.registry.address with the repository in a
# sibling key, so there is no trailing slash for the literal "<src>/" replace
# to match. The image was mirrored, the host was not rewritten, and the
# published tree kept pointing at the private CI registry. Dormant while the
# image was built in cozystack/kubeovn-chart; live once it is built here.
#
# Needs a skopeo stub because the sed only runs outside --dry-run. Scope: this
# covers the whole-value shape only.
tmp=$(mktemp -d)
_make_tree "$tmp/tree"
# The script verifies every dest tag resolves to the source digest and aborts
# before the rewrite otherwise, so the registry stub has to answer with it.
_stub_registry "$tmp/bin"
PATH="$tmp/bin:$PATH" hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree"
# the whole-value host is rewritten, and the repository beside it is untouched
grep -q '^ address: ghcr.io/cozystack/cozystack$' "$tmp/tree/system/globalreg/values.yaml"
grep -q '^ repository: globalreg$' "$tmp/tree/system/globalreg/values.yaml"
if grep -q 'iad.ocir.io' "$tmp/tree/system/globalreg/values.yaml"; then echo "FAIL: the source registry host must not survive the rewrite"; false; fi
# a contiguous ref is still rewritten exactly once, not double-prefixed
grep -q '^image: ghcr.io/cozystack/cozystack/foo:main@sha256:' "$tmp/tree/system/foo/values.yaml"
# third-party hosts are left alone
grep -q 'docker.io/clastix/kubectl' "$tmp/tree/system/third/values.yaml"
rm -rf "$tmp"
}
@test "the dest tag is verified against the index digest, not the host platform's child" {
# Once main publishes multi-arch indexes, the tree pins index digests. Plain
# `skopeo inspect` reports the digest of the child manifest for the host
# platform, which never equals the index digest, so every mirror would fail
# its own verification. The stub reports that child digest for a plain inspect.
tmp=$(mktemp -d)
_make_tree "$tmp/tree"
_stub_registry "$tmp/bin"
PATH="$tmp/bin:$PATH" hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree"
# A dest tag holding different bytes still fails the verification.
_make_tree "$tmp/tree2"
rc=0
PATH="$tmp/bin:$PATH" MOCK_RAW=something-else hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree2" 2>"$tmp/err" || rc=$?
[ "$rc" -ne 0 ]
grep -q "expected 'sha256:$D'" "$tmp/err"
rm -rf "$tmp"
}
@test "the host rewrite reaches a host kept in repository beside an empty registry" {
# keycloak-operator's layout: the upstream chart joins `registry` and
# `repository`, so the package sets `registry: ""` and keeps the whole host in
# `repository`, where the literal "<src>/" replace can reach it. The image
# must be both mirrored and rewritten, or the published tree points at an
# image the dest registry does not have or at the private CI registry.
tmp=$(mktemp -d)
_make_tree "$tmp/tree"
mkdir -p "$tmp/tree/system/emptyreg"
{
echo 'image:'
echo ' registry: ""'
echo ' repository: iad.ocir.io/idyksih5sir9/cozystack/emptyreg'
printf ' tag: main@sha256:%s\n' "$D"
} > "$tmp/tree/system/emptyreg/values.yaml"
_stub_registry "$tmp/bin"
hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree" --dry-run >"$tmp/out"
grep -q 'docker://ghcr.io/cozystack/cozystack/emptyreg:0.0.0-nightly.test' "$tmp/out"
PATH="$tmp/bin:$PATH" hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/tree"
grep -q '^ repository: ghcr.io/cozystack/cozystack/emptyreg$' "$tmp/tree/system/emptyreg/values.yaml"
grep -q '^ registry: ""$' "$tmp/tree/system/emptyreg/values.yaml"
rm -rf "$tmp"
}
@test "keycloak-operator's stamped image reaches the nightly mirror and its host rewrite" {
# Runs the package's real `image:` recipe against the CI build registry and
# then the mirror over the result, so a regression in either the values
# layout or the Makefile stamp fails here. The chart joins `registry` and
# `repository`; if the host moves back into `registry`, or the recipe stops
# stamping `repository`, the stamped ref no longer carries the build host
# contiguously and is either never mirrored or left pointing at it.
D="$(printf 'c%.0s' $(seq 1 64))"
tmp=$(mktemp -d)
mkdir -p "$tmp/repo/packages/system" "$tmp/bin"
cp -R packages/system/keycloak-operator "$tmp/repo/packages/system/"
ln -s "$PWD/hack" "$tmp/repo/hack"
# docker stub: only writes the buildx metadata file the recipe reads.
{
echo '#!/bin/sh'
echo 'while [ $# -gt 0 ]; do'
echo ' if [ "$1" = --metadata-file ]; then'
printf ' printf %s > "$2"\n' "'{\"containerimage.digest\":\"sha256:$D\"}'"
echo ' fi'
echo ' shift'
echo 'done'
} > "$tmp/bin/docker"
chmod +x "$tmp/bin/docker"
_stub_registry "$tmp/bin"
PATH="$tmp/bin:$PATH" make --no-print-directory -s -C "$tmp/repo/packages/system/keycloak-operator" image \
REGISTRY=iad.ocir.io/idyksih5sir9/cozystack IMAGE_TAG=main COZYSTACK_VERSION=0.0.0 >/dev/null
hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/repo/packages" --dry-run >"$tmp/out"
grep -q 'docker://ghcr.io/cozystack/cozystack/keycloak-operator:0.0.0-nightly.test' "$tmp/out"
PATH="$tmp/bin:$PATH" hack/nightly-mirror.sh 0.0.0-nightly.test "$tmp/repo/packages"
if grep -q 'iad.ocir.io' "$tmp/repo/packages/system/keycloak-operator/values.yaml"; then
echo "the build registry host survived the nightly rewrite" >&2
false
fi
rm -rf "$tmp"
}