mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 02:27: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>
362 lines
15 KiB
Bash
362 lines
15 KiB
Bash
#!/usr/bin/env bats
|
|
# EXIT-TRAP DEBT: 12 -- see hack/bats-no-exit-trap.bats; lower it as the traps go, delete it at zero.
|
|
# Behavioural tests for the multus install-cni-plugins init container script:
|
|
# extracted from the rendered DaemonSet and run. The helm-unittest cases in
|
|
# packages/system/multus/tests/multus_test.yaml match its source TEXT instead.
|
|
#
|
|
# Run via hack/cozytest.sh from the repo root (make bats-unit-tests); relative
|
|
# paths resolve against that cwd. The runner has no setup/teardown, so each
|
|
# @test builds its own fixture.
|
|
|
|
CHART=packages/system/multus
|
|
|
|
# Extract the init container's script from the rendered chart into $1 and rebind
|
|
# its two absolute paths ($2 = host cni bin, $3 = plugin source) so it can run
|
|
# unprivileged. The non-empty check must be an early `return 1`: hack/cozytest.sh
|
|
# rewrites every line matching ^}$ into `return 0` plus `}`, helpers included.
|
|
render_script() {
|
|
out=$1
|
|
# The chart's own default is off -- the platform turns it on per bundle -- so
|
|
# the init container this file tests only exists when it is set here.
|
|
helm template --set stageCniPlugins=true "$CHART" \
|
|
| yq eval 'select(.kind == "DaemonSet") | .spec.template.spec.initContainers[] | select(.name == "install-cni-plugins") | .command[2]' - \
|
|
| sed -e "s|/host/opt/cni/bin|$2|g" -e "s|/cni-plugins|$3|g" \
|
|
-e "s|/dev/termination-log|${out}.termlog|g" > "$out"
|
|
[ -s "$out" ] || return 1
|
|
|
|
# Nothing may still point at a real /opt/cni/bin: the rebinding substitutes
|
|
# /host/opt/cni/bin literally, and these tests EXECUTE the script.
|
|
if grep -q '/opt/cni/bin' "$out"; then
|
|
echo "rendered script still references a real /opt/cni/bin after rebinding;" >&2
|
|
echo "refusing to execute it. Check that the manifest still writes through" >&2
|
|
echo "/host/opt/cni/bin, which is what this rebinding substitutes." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# The names the image actually stages, read from the Dockerfile rather than
|
|
# repeated here: a script that skips a plugin by name is only exercised against
|
|
# a fixture that contains that name.
|
|
plugin_names() {
|
|
sed -n '/tar -xz -C \/cni-plugins -f /,/;/p' \
|
|
packages/system/multus/images/multus-cni/Dockerfile |
|
|
tr ' ' '\n' | sed -n 's|^\./||p' | tr -d ';' | sort
|
|
}
|
|
|
|
# A fake image payload: plugin binaries that report their own name when run.
|
|
make_plugins() {
|
|
mkdir -p "$1"
|
|
for p in $(plugin_names); do
|
|
printf '#!/bin/sh\necho %s-NEW\n' "$p" > "$1/$p"
|
|
chmod 0755 "$1/$p"
|
|
done
|
|
}
|
|
|
|
# Put an `rm` in $1 that refuses any path containing $2, leaving it in place, and
|
|
# delegates the rest. Injected rather than provoked with permissions: root's DAC
|
|
# override unlinks through a forbidding parent, so a filesystem-shaped fixture
|
|
# stops reproducing wherever the suite runs as root. $2 is narrow so staging can
|
|
# still succeed around one bad path.
|
|
stub_failing_rm() {
|
|
mkdir -p "$1"
|
|
sed -e "s|@MATCH@|$2|" > "$1/rm" <<'STUB'
|
|
#!/bin/sh
|
|
for a in "$@"; do
|
|
case $a in
|
|
*@MATCH@*)
|
|
echo "rm: $a: Read-only file system" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
exec /bin/rm "$@"
|
|
STUB
|
|
chmod 0755 "$1/rm"
|
|
}
|
|
|
|
@test "a render that produces no script fails instead of passing as a no-op" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
mkdir -p "$tmp/dst"
|
|
# Guards the guard. Two tests below assert nothing landed in the destination,
|
|
# so an unrendered script satisfies them. This also fails if the check is
|
|
# moved into last-command position, where cozytest.sh's `return 0` rewrite
|
|
# swallows its status. Each @test runs in its own subshell, so reassigning
|
|
# CHART cannot leak.
|
|
CHART=$tmp/no-such-chart
|
|
if render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src" 2>/dev/null; then
|
|
echo "FAIL: render_script reported success with no chart to render"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "installs every staged plugin into the host cni bin dir, executable" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh"
|
|
|
|
names=$(plugin_names)
|
|
[ -n "$names" ]
|
|
[ "$(printf '%s\n' "$names" | grep -c .)" = "14" ]
|
|
[ "$(ls -1 "$tmp/dst" | wc -l | tr -d ' ')" = "14" ]
|
|
# Each by name: a count is satisfied by that many of anything, and one plugin
|
|
# landing unexecutable leaves the others to carry the assertion.
|
|
for p in $names; do
|
|
[ -f "$tmp/dst/$p" ]
|
|
[ -x "$tmp/dst/$p" ]
|
|
[ "$("$tmp/dst/$p")" = "$p-NEW" ]
|
|
done
|
|
}
|
|
|
|
@test "installs a plugin executable even when the staged copy is not" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# cp reproduces the source mode, so the chmod is only load-bearing when a
|
|
# staged plugin is not already 0755.
|
|
chmod 0644 "$tmp/src/bridge"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh"
|
|
|
|
# Two statements, not `[ -f X ] && [ -x X ]`: a failing command that is not the
|
|
# last in an AND-OR list is exempt from errexit, so the pair silently continues
|
|
# into cozytest.sh's appended `return 0` and the whole assertion is discarded.
|
|
# Measured in dash and bash -- the `-f` half of every such pair was dead.
|
|
[ -f "$tmp/dst/bridge" ]
|
|
[ -x "$tmp/dst/bridge" ]
|
|
}
|
|
|
|
@test "replaces a plugin by rename, not by writing onto the live path" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# Stand in for a plugin another CNI already installed and the kubelet may exec.
|
|
printf '#!/bin/sh\necho portmap-OLD\n' > "$tmp/dst/portmap"
|
|
chmod 0755 "$tmp/dst/portmap"
|
|
before=$(ls -i "$tmp/dst/portmap" | awk '{print $1}')
|
|
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh"
|
|
|
|
after=$(ls -i "$tmp/dst/portmap" | awk '{print $1}')
|
|
[ "$("$tmp/dst/portmap")" = "portmap-NEW" ]
|
|
# A rename swaps in a new inode, so an exec holding the old one keeps a
|
|
# complete binary; a cp onto the live path truncates and rewrites in place.
|
|
if [ "$before" = "$after" ]; then echo "FAIL: portmap replaced in place (inode $before unchanged) — copy was not atomic"; false; fi
|
|
}
|
|
|
|
@test "leaves no temp files behind, and clears ones stranded by an earlier kill" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# Named for a plugin this image does not stage, so no iteration renames it
|
|
# away -- only the cleanup can remove it.
|
|
echo stranded > "$tmp/dst/.tmp-cozystack-multus-plugin-from-an-older-image"
|
|
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh"
|
|
|
|
[ "$(ls -a1 "$tmp/dst" | grep -c '^\.tmp-cozystack-multus-' || true)" = "0" ]
|
|
}
|
|
|
|
@test "skips staging instead of failing when the image predates the plugins" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
mkdir -p "$tmp/dst"
|
|
# No source dir at all: the release-prep digest re-pin lags a Dockerfile
|
|
# change, so the pinned image can have no /cni-plugins.
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/absent"
|
|
sh "$tmp/s.sh" 2>"$tmp/err"
|
|
|
|
[ "$(ls -1 "$tmp/dst" | wc -l | tr -d ' ')" = "0" ]
|
|
# The empty destination alone is satisfied by the guard being gone: the glob
|
|
# stays literal and `[ -e "$plugin" ]` skips it. Only this line separates them.
|
|
grep -q 'in this image; skipping staging' "$tmp/err"
|
|
# And nothing may be reported as failed: a skip is not a partial install.
|
|
if grep -q 'failed to install:' "$tmp/err"; then
|
|
echo "FAIL: an absent plugin directory was reported as failed installs"
|
|
cat "$tmp/err"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "does not fail when the staged plugin directory is empty" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
mkdir -p "$tmp/src" "$tmp/dst"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
# An unmatched glob stays literal in sh, so without the `[ -e ]` guard the loop
|
|
# runs once for a path that does not exist and reports a plugin named `*`.
|
|
sh "$tmp/s.sh" 2>"$tmp/err"
|
|
|
|
[ "$(ls -1 "$tmp/dst" | wc -l | tr -d ' ')" = "0" ]
|
|
# Asserted, because the empty destination alone is satisfied by the guard
|
|
# being gone.
|
|
if grep -q 'failed to install:' "$tmp/err"; then
|
|
echo "FAIL: an empty plugin directory was reported as failed installs"
|
|
cat "$tmp/err"
|
|
false
|
|
fi
|
|
}
|
|
|
|
@test "a leftover directory is cleared, and the plugin installs as a file" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# The temp path as an ordinary directory `rm -rf` can clear: a previous run
|
|
# killed between the cp and the rename. Staging proceeds.
|
|
#
|
|
# The assertions check for a regular FILE: `-x` alone is true of a directory.
|
|
mkdir -p "$tmp/dst/.tmp-cozystack-multus-bridge"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh"
|
|
|
|
[ -f "$tmp/dst/bridge" ]
|
|
[ -x "$tmp/dst/bridge" ]
|
|
[ "$("$tmp/dst/bridge")" = "bridge-NEW" ]
|
|
}
|
|
|
|
@test "a temp path that will not clear is refused, not published as a plugin" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# A temp path that is still a directory when the copy runs: cp writes INTO it,
|
|
# chmod works on it, and mv publishes it as the plugin, because a rename onto
|
|
# a free name succeeds. The runtime cannot exec a directory.
|
|
mkdir -p "$tmp/dst/.tmp-cozystack-multus-bridge"
|
|
stub_failing_rm "$tmp/bin" ".tmp-cozystack-multus-bridge"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
|
|
# Still must not fail the container. hostNetwork pods bypass CNI and would
|
|
# start either way; every other pod on the node would not.
|
|
if ! PATH="$tmp/bin:$PATH" sh "$tmp/s.sh" 2>"$tmp/err"; then
|
|
echo "FAIL: an unclearable temp path failed the container"
|
|
cat "$tmp/err"
|
|
false
|
|
fi
|
|
if [ -d "$tmp/dst/bridge" ]; then
|
|
echo "FAIL: a directory was published as the plugin"
|
|
ls -la "$tmp/dst/bridge"
|
|
false
|
|
fi
|
|
# Refused, so nothing is there at all -- not a half-installed something.
|
|
if [ -e "$tmp/dst/bridge" ]; then
|
|
echo "FAIL: expected bridge to be absent after a refusal"
|
|
false
|
|
fi
|
|
# The injection has to have happened: if PATH stopped being honoured, the real
|
|
# `rm` would clear the fixture and every assertion above would still pass.
|
|
if [ ! -d "$tmp/dst/.tmp-cozystack-multus-bridge" ]; then
|
|
echo "FAIL: the temp path was removed, so no failure was injected;"
|
|
echo " this test proved nothing. Check that stub_failing_rm is on PATH"
|
|
echo " and that the script still invokes rm by bare name."
|
|
false
|
|
fi
|
|
# And WHICH plugin: anchored at line start, matched as a whole token. The stub
|
|
# prints the bridge path itself, `.*bridge` would also accept `bridgeport`,
|
|
# and without `^` the contradictory `not failed to install:` passes too.
|
|
grep -qE '^failed to install:( [^ ]+)* bridge( |$)' "$tmp/err"
|
|
# The termination log is the channel the stub cannot write to, so assert there
|
|
# too -- it is also what `kubectl describe` shows.
|
|
grep -qE '^failed to install:( [^ ]+)* bridge( |$)' "$tmp/s.sh.termlog"
|
|
# The other plugins are unaffected: one bad temp path is not a stop signal.
|
|
[ -f "$tmp/dst/macvlan" ]
|
|
[ -x "$tmp/dst/macvlan" ]
|
|
[ "$("$tmp/dst/macvlan")" = "macvlan-NEW" ]
|
|
}
|
|
|
|
@test "refuses to nest a plugin inside a directory that already holds its name" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# mv moves its source INTO an existing directory, under the SOURCE's basename
|
|
# -- the temp name, never <dst>/bridge/bridge. Assert the directory gained no
|
|
# entries: a temp file one level down is deeper than the trap's glob.
|
|
mkdir -p "$tmp/dst/bridge/occupied"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
sh "$tmp/s.sh" 2>"$tmp/err"
|
|
|
|
nested=$(ls -A "$tmp/dst/bridge" | grep -v '^occupied$' || :)
|
|
if [ -n "$nested" ]; then
|
|
echo "FAIL: something was moved inside the existing directory: $nested"
|
|
false
|
|
fi
|
|
# One line, summary and name together: as two greps this passes when the
|
|
# summary names a different plugin and `mv` merely printed this one.
|
|
grep -qE '^failed to install:( [^ ]+)* bridge( |$)' "$tmp/err"
|
|
# And a CAUSE: this is the one branch that records a failure no command
|
|
# printed an error for, so without this line the summary is unexplained.
|
|
grep -q 'is a directory, not a plugin binary' "$tmp/err"
|
|
}
|
|
|
|
@test "survives a leftover it cannot clean up" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# A leftover only the trap's glob reaches. Its `rm` is guarded because a
|
|
# command failing inside an EXIT trap sets the status even after `exit 0`.
|
|
mkdir -p "$tmp/dst/.tmp-cozystack-multus-stranded"
|
|
stub_failing_rm "$tmp/bin" "stranded"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
|
|
if ! PATH="$tmp/bin:$PATH" sh "$tmp/s.sh" 2>"$tmp/err"; then
|
|
echo "FAIL: a leftover that could not be removed failed the container;"
|
|
echo " every pod that needs CNI would then fail to start here"
|
|
cat "$tmp/err"
|
|
false
|
|
fi
|
|
# The leftover has to have SURVIVED: that is the only proof the failure was
|
|
# injected, since a real `rm -rf` would clear it and staging would succeed.
|
|
if [ ! -d "$tmp/dst/.tmp-cozystack-multus-stranded" ]; then
|
|
echo "FAIL: the stranded leftover was removed, so no failure was injected;"
|
|
echo " this test proved nothing about the EXIT trap's guard."
|
|
false
|
|
fi
|
|
# And the staging still has to have happened.
|
|
[ -f "$tmp/dst/bridge" ]
|
|
[ -x "$tmp/dst/bridge" ]
|
|
[ "$("$tmp/dst/bridge")" = "bridge-NEW" ]
|
|
}
|
|
|
|
@test "reports a plugin it could not install, and still leaves the node usable" {
|
|
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
make_plugins "$tmp/src"; mkdir -p "$tmp/dst"
|
|
# An entry cp cannot copy, sorted ahead of the good plugins so a later
|
|
# iteration still runs.
|
|
mkdir -p "$tmp/src/aaa-uncopyable"
|
|
# Plus a leftover the trap cannot remove, so both failure paths run at once:
|
|
# unguarded, the script dies in the EXIT trap before the summary.
|
|
mkdir -p "$tmp/dst/.tmp-cozystack-multus-stranded"
|
|
stub_failing_rm "$tmp/bin" "stranded"
|
|
render_script "$tmp/s.sh" "$tmp/dst" "$tmp/src"
|
|
|
|
# Both halves: exit 0 alone would pass with the message dropped, and the log
|
|
# line is the only remaining signal.
|
|
if ! PATH="$tmp/bin:$PATH" sh "$tmp/s.sh" 2>"$tmp/err"; then
|
|
echo "FAIL: a plugin that could not be installed failed the container;"
|
|
echo " every pod that needs CNI would then fail to start here"
|
|
false
|
|
fi
|
|
# The script's own wording AND the name, as one line: cp prints the path it
|
|
# could not copy, so either half alone passes with the report deleted.
|
|
if ! grep -qE '^failed to install:( [^ ]+)* aaa-uncopyable( |$)' "$tmp/err"; then
|
|
echo "FAIL: the script did not report the plugin it could not install"
|
|
cat "$tmp/err"
|
|
false
|
|
fi
|
|
|
|
# And into the termination log, which is what `kubectl describe` shows.
|
|
# invisible to anyone not reading that container's logs specifically.
|
|
if ! grep -q 'failed to install:.*aaa-uncopyable' "$tmp/s.sh.termlog" 2>/dev/null; then
|
|
echo "FAIL: the failure was not written to the termination log"
|
|
false
|
|
fi
|
|
|
|
# The stranded leftover has to have survived, or the injected failure never
|
|
# happened and the both-go-wrong path was not exercised.
|
|
if [ ! -d "$tmp/dst/.tmp-cozystack-multus-stranded" ]; then
|
|
echo "FAIL: the stranded leftover was removed, so no rm failure was injected"
|
|
false
|
|
fi
|
|
|
|
# The failure must not take the rest of the set with it.
|
|
[ -f "$tmp/dst/bridge" ]
|
|
[ -x "$tmp/dst/bridge" ]
|
|
[ "$("$tmp/dst/bridge")" = "bridge-NEW" ]
|
|
# And no temp FILE of its own. The planted directory is expected to survive:
|
|
# stub_failing_rm refuses that path, which is what makes the cleanup fail.
|
|
# Counting only regular files keeps the fixture from hiding a real leak.
|
|
[ "$(find "$tmp/dst" -maxdepth 1 -type f -name '.tmp-cozystack-multus-*' | wc -l | tr -d ' ')" = "0" ]
|
|
}
|