Files
cozystack/hack/multus-install-cni-plugins.bats
Aleksei Sviridkin 7739d1e294 feat(multus)!: stage reference CNI plugins from the multus image
Multus-backed NetworkAttachmentDefinitions delegate to the upstream
containernetworking reference plugins (bridge, macvlan, ipvlan, ...),
and nothing guarantees a node has them in the directory Multus
searches.  On k3s they live under /var/lib/rancher/k3s/data/cni while
Multus and Cilium install into /opt/cni/bin, so a bridge-type NAD fails
there with `failed to find plugin "bridge" in path [/opt/cni/bin]`.

Bundle 14 of the release's plugins into the multus image and add an
install-cni-plugins init container that installs them into the host
/opt/cni/bin, mirroring the existing install-multus-binary container.
loopback, dhcp, tap and dummy are left out; the Dockerfile records why.

Gate it on a new chart value, stageCniPlugins, resolved per bundle and
on for both.  Talos bundles only bridge, firewall, flannel, host-local,
loopback and portmap, so the other ten are missing there exactly as on
generic Linux; the four that overlap are rewritten with the same
upstream version, v1.9.1 on both sides, rather than an older one.  A
marker in the image Dockerfile records the Talos release that pin was
compared against, and a test holds it to the release the platform
ships.  multus therefore moves out of system.common-packages into the
two variant branches, the way linstor already does for talos.enabled.
networking.stageCniPlugins overrides the variant default in either
direction.

Where staging is on the install is unconditional: a plugin is replaced
on every pod recreation, not only when the destination is missing.  A
destination that is already a directory is reported and left alone,
since replacing it is not something this can do safely.  Turning the
value off afterwards does not undo it; nothing here removes or
restores a plugin.

The container is not privileged and takes no mount propagation.  It
runs in the spc_t SELinux domain, without which an enforcing node
refuses the write.  Each plugin is installed under a temporary name and
swapped in with a rename: /opt/cni/bin is shared with cilium and
kube-ovn.  The plugin tarball is checksummed before extraction.

A plugin that cannot be installed is reported on stderr and to
/dev/termination-log, and the container still exits 0: Multus stays the
node's primary CNI and nothing removes its config, so an init container
that never completes would leave the shim with no daemon behind it.

The manifest change lives in customize-deployment.patch so it survives
make update.

BREAKING CHANGE: enabling stageCniPlugins replaces reference CNI
plugins in /opt/cni/bin on existing nodes, Talos included -- four of the
names it writes are ones the Talos node image also carries.  Opt out
before upgrading: declining afterwards does not restore what was
replaced.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-05 14:19:47 +03:00

361 lines
15 KiB
Bash

#!/usr/bin/env bats
# 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" ]
}