mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 16:14:01 +09:00
The ban on EXIT-trap cleanup in hack/*.bats was enforced by two lists inside hack/cozyreport.bats: one naming the files known to be clean, one freezing an exact trap count for each file that was not. Both had to be edited from whatever change moved them, and that is where the guard kept failing. A change adding a trap to its own file had to edit a string in a suite it otherwise never touches, so two changes sharing no line still invalidated each other: each stayed green against its own base, git merged both cleanly, and the guard went red only once the second landed. A file arriving with traps hit the same wall from the other side -- the inventory did notice it, since the string it compared was built by scanning the directory, but absorbing it meant an edit in a file nothing in the author's diff pointed at. Replace both lists with a declaration each file makes about itself: one "# EXIT-TRAP DEBT: N" comment, exact rather than a ceiling. A file carrying none must install no EXIT trap, which covers the converted files, the files that never had one, and every file added later. Growing or shedding a trap now fails in the file the change already edits, so two changes that disagree about a count collide textually instead of silently. The declaration is read from the leading comment block, not from anywhere in the file. A .bats file is shell that writes shell, so the same line turns up inside a heredoc, a fixture writer or an expected-output string, where it is data belonging to one test. Reading it there as a statement about the whole file would let an unrelated fixture excuse a real trap, and would do it silently, since nothing in that test's own diff looks like a declaration. A comment block is the region with no interior: stopping instead at the first @test would still read a line out of a helper's heredoc. Not every counted handler is debt. A trap inside an explicit subshell does not replace the bats binary's own, so a test failing inside `( ... )` still prints its `not ok`. hack/e2e-test-openapi.bats kills a backgrounded kubectl proxy that way, and moving the kill to the end of the body would leak a process holding a fixed port. Its declaration records that rather than scheduling a conversion, and because the ratchet is exact, removing the trap fails too -- the count protects the construct. What the count cannot do is tell the two apart: substituting a test-level trap for the subshell one keeps the total at 1 and stays green, which the header states rather than leaves to be found. Counting bounds the keyword and the signal the same way, at any character that cannot be part of an identifier, and matches the signal in either case. Whitespace on the right missed `trap ... EXIT; cd "$tmp"`; whitespace on the left missed `tmp=$(mktemp -d);trap ... EXIT` and `(trap ... EXIT; true)`; upper case missed `trap ... exit`, which bash and dash both install. All are real handlers that scored zero, and the left boundary matters most, since the inventory being replaced had none and caught the semicolon form. A bare word boundary is not enough either way: `bootstrap ` ends in `trap `, and it must keep scoring nothing. A quoted signal counts for the same reason as the rest. Two handlers sharing one line are reported rather than counted, since the count is a count of lines and the second would otherwise arrive without moving the total; splitting the line properly needs a shell parser, a semicolon inside a handler's own action not being a separator, and guessing wrong undercounts -- the one direction a ratchet cannot afford. The scan recurses, so hack/e2e-apps/*.bats is covered rather than sitting one directory below the guard that claims the tree. It reads .bats and nothing else, so a handler arriving through a sourced .sh stays outside it -- hack/e2e-chainsaw/_lib/run-kubernetes.sh installs two, each benign for its own reason rather than by design: one sits in a function declared with `(` and so runs in a subshell, the other in a brace function no @test calls. That boundary is stated in the header rather than papered over. The guard moves to hack/bats-no-exit-trap.bats: its subject is every unit suite under hack/, not the report collector it grew up in. Its fixture helpers assemble the trap keyword and the signal from separate arguments, because the guard scans its own source and a fixture written as a literal would be counted as a real trap in it. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
291 lines
15 KiB
Bash
291 lines
15 KiB
Bash
#!/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 "<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. 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"
|
|
}
|