Files
cozystack/hack/kubernetes-pre-delete-hook.bats
Ray Low 904bc38fc3 fix(kubernetes): delete the Talos PKI and oidc-bootstrap Job on uninstall
cert-manager runs with enableCertificateOwnerRef: false, so the
<release>-talos-ca and <release>-talos-tls-cert Secrets carry no
ownerReference to their Certificates and outlive the release, keeping
the CA private key of a cluster that no longer exists. A new cluster
created under the same name then adopts that stale CA instead of
issuing its own trust root.

The <release>-oidc-bootstrap Job is a hook whose only delete policy is
before-hook-creation. Hooks are not part of the release manifest, so
uninstall never removes it.

The pre-delete hook deletes the two Certificates before their Secrets,
because deleting a Secret while its Certificate exists makes
cert-manager re-issue it. It runs after the TenantControlPlane is gone,
since the control plane's CSR signer mounts the CA. The Role grants
each delete by resourceName only.

Assisted-by: LLM
Signed-off-by: Ray Low <crossrt@gmail.com>
2026-10-11 22:40:47 +08:00

430 lines
16 KiB
Bash

#!/usr/bin/env bats
# Behavioural tests for the pre-delete cleanup hook of packages/apps/kubernetes:
# the script is extracted from the rendered Job and executed against a stub
# kubectl. The helm-unittest cases in
# packages/apps/kubernetes/tests/delete_hook_test.yaml match its source TEXT,
# which cannot say whether the closing message is reachable once a backstop has
# swallowed a failure.
#
# Run from the repo root through `make bats-unit-tests`; relative paths resolve
# against that cwd. The file also remains compatible with the legacy
# `hack/cozytest.sh` translator, so each @test builds and removes its fixture
# inline instead of relying on setup()/teardown(). No EXIT
# traps: a test that dies inside one prints neither `not ok` nor a reason, and
# the suite reads green. The removal is therefore reached only on a passing run;
# a failing one leaves its temp directory behind, which is what you want when
# reading the rendered script and the captured output afterwards.
load test_helper
CHART=packages/apps/kubernetes
# The stub is driven by two independent lists of globs, matched against the
# joined arguments, and keeping them separate is the point: the script under
# test distinguishes "this object is not there" from "I could not ask", so a
# fixture that spelled both as one failing call could not tell whether an
# assertion passed for the right reason.
#
# $KUBECTL_FAIL -- these invocations exit non-zero: the apiserver refused,
# timed out, or was unreachable.
# $KUBECTL_PRESENT -- these `get ... -o name` invocations print an object.
# Anything not listed exits 0 with no output, which is
# what `--ignore-not-found` does for an absent object.
# The ordinary end state after Step 4 waited: the TenantControlPlane is gone and
# the datastore Secret is still there for Step 4b to clear.
PRESENT_SECRET_ONLY='*get secret*'
# Step 4's delete timed out with the TenantControlPlane still alive, so Step 4b
# must decline to touch a finalizer Kamaji may still own.
FAIL_TCP_DELETE='*delete tenantcontrolplanes*'
PRESENT_TCP_AND_SECRET='*get tenantcontrolplanes*
*get secret*'
# The Secret is there, and the patch that would clear its finalizer is rejected.
FAIL_SECRET_PATCH='*patch secret*'
# Step 2's bounded delete times out, so the run takes the force-clear route --
# and every force-clear then succeeds. A changed route is not residue: nothing
# survives this run, so it must still be reported as a clean one.
FAIL_HR_DELETE='*delete helmreleases*'
# ... and the re-list driving that force-clear fails too. The loop body never
# runs, so no patch is attempted and no per-object backstop can fire.
FAIL_HR_DELETE_AND_LIST='*delete helmreleases*
*get helmreleases*'
# Each of Step 4b's two probes, failing on its own. Neither says the object is
# absent -- they say the script could not find out, which is a third state.
FAIL_TCP_PROBE='*get tenantcontrolplanes*'
FAIL_SECRET_PROBE='*get secret*'
# Every failure at once. The two HelmRelease globs are distinguished by the
# patch body: Step 1 suspends (no "finalizers" in its payload) and must keep
# working, because it is not backstopped and `set -e` would end the run there.
FAIL_ALL='*delete helmreleases*
*patch helmrelease*finalizers*
*delete tenantcontrolplanes*
*get tenantcontrolplanes*
*patch secret*'
# Extract the pre-delete Job's script from the rendered chart into $1. The
# non-empty check must be an early `return 1`: hack/cozytest.sh rewrites every
# line matching ^}$ into `return 0` plus `}`, helpers included, so a check left
# in last position has its status discarded.
#
# helm's stderr is kept rather than discarded, because it carries the reason a
# render failed and without it this returns 1 with nothing to read. Only the
# per-render notice about the charts/cozy-lib symlink is dropped, and only when
# there is a failure to report.
render_script() {
helm template test-k8s "$CHART" \
--namespace tenant-test \
--values "$CHART/tests/values-ci.yaml" \
--show-only templates/delete.yaml 2>"$1.err" |
yq 'select(.kind == "Job") | .spec.template.spec.containers[0].command[2]' - > "$1"
if [ ! -s "$1" ]; then
echo "FAIL: the chart rendered no pre-delete Job script" >&2
grep -v 'found symbolic link in path' "$1.err" >&2 || true
return 1
fi
}
# A stub kubectl for $1/kubectl. `get ... -o name` answers for the three object
# kinds the script probes; the HelmRelease list returns TWO children so that a
# per-object failure in the force-clear loop hits the same backstop label twice
# and the once-per-step property has something to be true about.
#
# The closing brace of the helper inside the heredoc is indented on purpose:
# hack/cozytest.sh rewrites every line matching ^}$ anywhere in this file, the
# heredoc included, and a `return 0` spliced into the stub would make every
# injected failure disappear.
make_kubectl() {
mkdir -p "$1"
cat > "$1/kubectl" <<'STUB'
#!/bin/sh
args="$*"
echo "$args" >> "${KUBECTL_LOG:-/dev/null}"
matches() {
[ -n "$1" ] || return 1
oldifs=$IFS
IFS='
'
for pat in $1; do
case "$args" in
$pat) IFS=$oldifs; return 0 ;;
esac
done
IFS=$oldifs
return 1
}
if matches "${KUBECTL_FAIL:-}"; then
echo "stub kubectl: injected failure: $args" >&2
exit 1
fi
case "$args" in
*"get helmreleases"*"-o name"*)
echo "helmrelease.helm.toolkit.fluxcd.io/cilium"
echo "helmrelease.helm.toolkit.fluxcd.io/coredns"
;;
*"get tenantcontrolplanes"*"-o name"*)
matches "${KUBECTL_PRESENT:-}" && echo "tenantcontrolplane.kamaji.clastix.io/test-k8s"
;;
*"get secret"*"-o name"*)
matches "${KUBECTL_PRESENT:-}" && echo "secret/test-k8s-datastore-config"
;;
esac
exit 0
STUB
chmod 0755 "$1/kubectl"
}
# Fixture: $tmp holds the rendered script and the stub kubectl.
setup_case() {
tmp=$(mktemp -d) || return 1
render_script "$tmp/s.sh" || return 1
make_kubectl "$tmp/bin" || return 1
}
# Run the hook with the injected failures in $1 and the existing objects in $2,
# leaving merged output in $tmp/out and the exit status in $rc. The status must
# not abort the test: it is itself one of the things under test. KUBECONFIG is
# neutered so that a stub that stopped being found on PATH cannot reach a real
# cluster.
run_hook() {
rc=0
# Truncated per call, not appended to: the "stub was reached" check below is
# vacuous on a second run against a log the first run already filled.
: > "$tmp/kubectl.log"
KUBECONFIG=/dev/null \
KUBECTL_LOG="$tmp/kubectl.log" \
KUBECTL_FAIL="$1" \
KUBECTL_PRESENT="$2" \
PATH="$tmp/bin:$PATH" sh "$tmp/s.sh" > "$tmp/out" 2>&1 || rc=$?
# The stub has to have been reached, or the assertions below measured
# something other than this script driving kubectl.
[ -s "$tmp/kubectl.log" ] || return 1
}
@test "claims success only when nothing was left behind" {
setup_case
run_hook "" "$PRESENT_SECRET_ONLY"
[ "$rc" = 0 ]
grep -qF 'Cleanup completed successfully' "$tmp/out"
# The clean run must exercise the Step 4b patch rather than skip it, or the
# success claim is about a script that did almost nothing.
grep -qF 'Removing finalizers from secret/test-k8s-datastore-config' "$tmp/out"
if grep -qF 'residue left by' "$tmp/out"; then
echo "FAIL: a clean run reported residue"
cat "$tmp/out"
return 1
fi
if grep -qF 'WARNING' "$tmp/out"; then
echo "FAIL: a clean run emitted a warning"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}
@test "a rejected finalizer patch withdraws the success claim and names the step" {
setup_case
run_hook "$FAIL_SECRET_PATCH" "$PRESENT_SECRET_ONLY"
[ "$rc" = 0 ]
# The defect this pins: the closing line used to assert success even here.
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success while the datastore-secret finalizer was left in place"
cat "$tmp/out"
return 1
fi
grep -qF 'Cleanup incomplete; residue left by: step 4b (datastore-secret finalizer)' "$tmp/out"
# And only that step: a summary that names every backstop unconditionally
# tells an operator no more than the old unconditional success line did.
if grep -qF 'step 2 (' "$tmp/out"; then
echo "FAIL: a step 2 backstop was reported without firing"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}
@test "a fallback route that succeeded is reported as a clean run" {
# Step 2's bounded delete times out and every force-clear that follows works,
# which is the ordinary outcome for a tenant with no working nodes -- the case
# the wait is bounded for in the first place. Nothing survives the run, so
# calling it incomplete would send an operator hunting for objects that are
# not there: the same class of untrue closing line this file exists to stop,
# only pointing the other way.
setup_case
run_hook "$FAIL_HR_DELETE" "$PRESENT_SECRET_ONLY"
[ "$rc" = 0 ]
# The route really was taken, or this passes for a run that never fell back.
grep -qF 'Force-clearing finalizers on helmrelease.helm.toolkit.fluxcd.io/cilium' "$tmp/out"
if grep -qF 'residue left by' "$tmp/out"; then
echo "FAIL: a fallback that left nothing behind was reported as incomplete"
cat "$tmp/out"
return 1
fi
grep -qF 'Cleanup completed successfully' "$tmp/out"
rm -rf "$tmp"
}
@test "an enumeration that failed is reported, not taken for an empty one" {
setup_case
run_hook "$FAIL_HR_DELETE_AND_LIST" "$PRESENT_SECRET_ONLY"
[ "$rc" = 0 ]
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success after it could not list what it had to force-clear"
cat "$tmp/out"
return 1
fi
grep -qF 'Cleanup incomplete; residue left by: step 2 (child HelmRelease enumeration)' "$tmp/out"
# And not the per-object label: that one means a patch was attempted and
# rejected. Here the loop never ran, so claiming it would invent a failure.
if grep -qF 'step 2 (child HelmRelease finalizers)' "$tmp/out"; then
echo "FAIL: a per-object patch failure was reported although the loop never ran"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}
@test "a TenantControlPlane outliving Step 4 is reported even though no command failed" {
# Step 4 times out with the TCP still alive, so Step 4b declines to strip a
# finalizer Kamaji may still own. Every kubectl call in Step 4b succeeds, so
# nothing is caught by a `||` -- and the namespace is still wedged. This is
# the one residue the script reports from a branch rather than from a failure.
setup_case
run_hook "$FAIL_TCP_DELETE" "$PRESENT_TCP_AND_SECRET"
[ "$rc" = 0 ]
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success with the datastore-secret finalizer left in place"
cat "$tmp/out"
return 1
fi
# The label names the TenantControlPlane, not the Secret. Both branches of
# Step 4b leave the finalizer in place, but only the other one wants it
# cleared by hand; here it is Kamaji's, and a summary pointing at the Secret
# would send the reader to strip a finalizer its owner is still using.
grep -qF 'Cleanup incomplete; residue left by: step 4b (TenantControlPlane still deleting)' "$tmp/out"
if grep -qF 'step 4b (datastore-secret finalizer)' "$tmp/out"; then
echo "FAIL: the summary blamed the Secret for a TenantControlPlane that is still deleting"
cat "$tmp/out"
return 1
fi
grep -qF 'is still present, so Kamaji may still own' "$tmp/out"
rm -rf "$tmp"
}
@test "a TenantControlPlane probe that failed is not read as absent" {
# The probe is the gate that stops the finalizer being stripped from an owner
# that still exists, so reading its failure as "confirmed gone" defeats it in
# the one direction that does damage. The script must neither strip nor claim
# the run was clean.
setup_case
run_hook "$FAIL_TCP_PROBE" ""
[ "$rc" = 0 ]
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success without establishing whether the TenantControlPlane was gone"
cat "$tmp/out"
return 1
fi
grep -qF 'Cleanup incomplete; residue left by: step 4b (datastore-secret state unknown)' "$tmp/out"
# And it must NOT have gone on to patch: that is the damage the gate prevents.
if grep -qF 'Removing finalizers from secret/' "$tmp/out"; then
echo "FAIL: the finalizer was stripped although the TenantControlPlane state was unknown"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}
@test "a Secret probe that failed is not read as absent" {
# The TCP is confirmed gone, so Step 4b is entitled to act -- but it cannot
# find out whether the Secret is there. "not present; nothing to do" would be
# an assertion the script never checked.
setup_case
run_hook "$FAIL_SECRET_PROBE" ""
[ "$rc" = 0 ]
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success without establishing whether the Secret was still there"
cat "$tmp/out"
return 1
fi
if grep -qF 'not present; nothing to do' "$tmp/out"; then
echo "FAIL: a failed probe was reported as the Secret being absent"
cat "$tmp/out"
return 1
fi
grep -qF 'Cleanup incomplete; residue left by: step 4b (datastore-secret state unknown)' "$tmp/out"
rm -rf "$tmp"
}
@test "every step that left residue is named in the closing message" {
setup_case
run_hook "$FAIL_ALL" ""
[ "$rc" = 0 ]
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success with every remedy having failed"
cat "$tmp/out"
return 1
fi
# Read the summary as one line: asserting the labels against the whole output
# would pass on the individual WARNINGs, which were already there. Two labels,
# not five: the timeouts that changed route left nothing of their own, and
# what survived them is what the per-object failures below record.
summary=$(grep -F 'residue left by:' "$tmp/out")
for label in \
'step 2 (child HelmRelease finalizers)' \
'step 4b (datastore-secret state unknown)'; do
case "$summary" in
*"$label"*) ;;
*)
echo "FAIL: the closing message did not name: $label"
echo "$summary"
return 1
;;
esac
done
# Once each. The force-clear loop fails on both child HelmReleases, and a
# summary that listed its step twice would read as two separate failures.
dupes=$(printf '%s\n' "$summary" | tr ',' '\n' |
grep -cF 'step 2 (child HelmRelease finalizers)' || true)
if [ "$dupes" != 1 ]; then
echo "FAIL: the step 2 finalizer backstop is named $dupes times, expected once"
echo "$summary"
return 1
fi
rm -rf "$tmp"
}
@test "a step with no backstop ends the run instead of claiming success" {
# The closing message is honest only while the steps that are NOT backstopped
# still end the run under `set -e`. Nothing but `set -e` enforces that, so a
# `|| true` appended to one of them later would reinstate the exact defect
# this file pins: a cleanup that did not happen, reported as success. Step 3
# stands in for Steps 3, 5, 6, 7 and the patch inside Step 1. Step 1's
# enumeration is NOT among them: it carries `|| true` on purpose, because
# Step 2 deletes by label rather than from that list, so a failure there
# leaves nothing behind to report.
setup_case
run_hook '*delete kamajicontrolplanes*' "$PRESENT_SECRET_ONLY"
if [ "$rc" = 0 ]; then
echo "FAIL: a failed Step 3 did not end the run"
cat "$tmp/out"
return 1
fi
if grep -qF 'Cleanup completed successfully' "$tmp/out"; then
echo "FAIL: cleanup claimed success after Step 3 failed"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}
@test "exits 0 whether cleanup was clean or every remedy failed" {
# Pinned on its own because it is the constraint the honesty fix must not buy
# its honesty with. This is a pre-delete hook: a non-zero exit aborts the Helm
# uninstall before the release is removed, so the release stays in storage and
# the same teardown is retried forever. Reporting failure through the exit
# code is what the closing message exists to avoid.
setup_case
run_hook "" "$PRESENT_SECRET_ONLY"
if [ "$rc" != 0 ]; then
echo "FAIL: a clean run exited $rc"
cat "$tmp/out"
return 1
fi
run_hook "$FAIL_ALL" ""
if [ "$rc" != 0 ]; then
echo "FAIL: an all-failures run exited $rc, which would wedge the release"
cat "$tmp/out"
return 1
fi
rm -rf "$tmp"
}