#!/usr/bin/env bats # EXIT-TRAP DEBT: 5 -- see hack/bats-no-exit-trap.bats; lower it as the traps go, delete it at zero. # 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), so it still cannot # rewrite a host split into a sibling `registry:` key (keycloak-operator). # That is a known gap recorded in docs/agents/image-refs.md, not something # this test covers; the whole-value shape (kubeovn) is covered by the # dedicated test below. 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$' } @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 "/" 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. keycloak-operator splits the host at a # different boundary (`registry: iad.ocir.io`), where no single key holds # SRC_REGISTRY, and is still unfixed — see docs/agents/image-refs.md. tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT _make_tree "$tmp/tree" # The stub must answer `inspect` with the source digest: the script verifies # every dest tag resolves to it and aborts before the rewrite otherwise. mkdir -p "$tmp/bin" { echo '#!/bin/sh' echo 'case "$1" in' printf ' inspect) echo "sha256:%s" ;;\n' "$D" echo ' *) exit 0 ;;' echo 'esac' } > "$tmp/bin/skopeo" chmod +x "$tmp/bin/skopeo" 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" ! grep -q 'iad.ocir.io' "$tmp/tree/system/globalreg/values.yaml" # 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" }