Files
cozystack/hack/kafka-pre-delete-hook.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

345 lines
14 KiB
Bash

#!/usr/bin/env bats
# Behavioural tests for the Kafka pre-delete hook:
#
# packages/apps/kafka/templates/hooks/delete.yaml
#
# The script is extracted from the rendered chart and RUN against a fake
# kubectl, because every property worth pinning here is an EXIT CODE and no
# amount of matching against the manifest text establishes one. The
# helm-unittest suite in packages/apps/kafka/tests/delete_test.yaml matches the
# source text instead, and stayed green through a mutation that put the timeout
# branch back to `exit 1` — which is the whole subject of this file.
#
# Why the exit code carries so much: a non-zero hook returns from Uninstall.Run
# before any release resource goes, so Helm leaves the topic operator running
# and helm-controller retries. That is the difference between "the uninstall
# waits" and "the topic operator is gone while topics still hold
# strimzi.io/topic-operator", which is issue #3793.
#
# What is pinned here:
# * a delete that succeeds exits 0;
# * a CRD that is absent is not a failure: the release may never have created
# a topic, and the uninstall must not wedge on that;
# * a wait that timed out exits 0 DELIBERATELY, so the uninstall can finish,
# and says so on stderr. This is the branch 2b47c5a5 added; reverting it
# must turn this file red;
# * any OTHER kubectl failure exits non-zero, so a Forbidden or an apiserver
# error cannot be mistaken for the timeout case and silently proceed;
# * the selector names this release only;
# * the release's KafkaNodePools are deleted after the topics, including on
# the timeout branch. The migration hook creates them outside the release
# manifest, and each owns the StrimziPodSet running its brokers, so a pool
# the hook skips keeps its brokers running after the release is gone;
# * a KafkaNodePool CRD that is absent exits 0, and any other failure of
# that delete exits non-zero, so the uninstall is retried rather than
# finishing with the pools still in place.
#
# Run from the repo root with make bats-unit-tests. Direct status checks
# retain compatibility with cozytest, which has no Bats run/setup helpers.
# Inline cleanup preserves Bats' TAP handler on failed assertions.
load test_helper
KAFKA_CHART=packages/apps/kafka
# render_hook <chart> <release> <namespace> <job-name> <out>
# Extract the named Job's shell script from the rendered chart into <out>. 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-command position has its status discarded.
render_hook() {
chart=$1 release=$2 namespace=$3 job=$4 out=$5
helm template "$release" "$chart" --namespace "$namespace" \
| yq eval "select(.kind == \"Job\" and .metadata.name == \"$job\") | .spec.template.spec.containers[0].command[2]" - > "$out"
[ -s "$out" ] || return 1
}
# write_fake_kubectl <dir>
# Emit a fake `kubectl` that logs its call to $KLOG and replays whatever
# $DELETE_OUT holds, exiting with $DELETE_RC. The hook reads kubectl's combined
# output and branches on its text, so the fixture is that text verbatim: the
# timeout string is what kubectl v1.32 prints from the wait path, and the
# CRD-absent strings are the two RESTMapper wordings.
# DELETE_RC deliberately has NO default: an unset value would make every
# failure-path test exit 0 and assert nothing. Each test sets it.
# The KafkaNodePool delete answers from POOL_OUT / POOL_RC instead, so a test
# can fail one call without failing the other. POOL_RC defaults to a clean
# delete, because the topic-path tests above are about the topic call; every
# pool-path test sets it explicitly.
# No line below is a bare column-0 `}`, so cozytest.sh's parser leaves it alone.
write_fake_kubectl() {
cat > "$1/kubectl" <<'KEOF'
#!/bin/sh
echo "$*" >> "$KLOG"
case "$*" in
*kafkanodepools*)
printf '%s\n' "${POOL_OUT:-kafkanodepool.kafka.strimzi.io \"b-0\" deleted}"
exit "${POOL_RC:-0}" ;;
esac
if [ -z "${DELETE_RC:-}" ]; then
echo "fake kubectl: DELETE_RC unset; the test would assert nothing" >&2
exit 64
fi
printf '%s\n' "${DELETE_OUT:-kafkatopic.kafka.strimzi.io \"t\" deleted}"
exit "$DELETE_RC"
KEOF
chmod +x "$1/kubectl"
}
@test "a render that produces no script fails instead of passing as a no-op" {
# Guards the guard: every test below runs whatever render_hook produced, so an
# empty render would satisfy the exit-0 assertions without executing a line of
# the hook.
tmp=$(mktemp -d)
if render_hook "$KAFKA_CHART" kafka-test tenant-test no-such-job "$tmp/hook.sh"; then
echo "FAIL: render_hook reported success for a Job name the chart never emits"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook exits 0 and deletes this release's topics only" {
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=0
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on a clean delete"
cat "$tmp/out" "$tmp/err"
false
fi
# Anchored on both sides. Bare "strimzi.io/cluster=kafka-test" is a prefix of
# the sibling release kafka-test2 and of kafka-test-topics, so a selector
# widened to either stays green against it: verified by rendering the hook
# with {{ .Release.Name }}2 and watching this test pass. The leading "-l " and
# the trailing space are what make it name this release and no other.
if ! grep -q -- "-l strimzi.io/cluster=kafka-test " "$tmp/calls"; then
echo "FAIL: the delete was not scoped to this release"
cat "$tmp/calls"
false
fi
if ! grep -q -- "--ignore-not-found=true" "$tmp/calls"; then
echo "FAIL: --ignore-not-found is gone, so an already-deleted topic fails the hook"
cat "$tmp/calls"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook exits 0 when the KafkaTopic CRD is absent" {
# A release that never created a topic, or a cluster where Strimzi is already
# gone. Failing here would wedge the uninstall over nothing to clean up. Both
# RESTMapper wordings are covered because kubectl picks between them.
for msg in 'error: the server doesn'"'"'t have a resource type "kafkatopics"' \
'error: no matches for kind "KafkaTopic" in version "kafka.strimzi.io/v1beta2"'; do
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=1
export DELETE_OUT="$msg"
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on an absent CRD, wedging the uninstall"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q 'CRD absent' "$tmp/out"; then
echo "FAIL: the absent CRD was not reported as such"
cat "$tmp/out"
false
fi
rm -rf "$tmp"
done
}
@test "kafka pre-delete hook exits 0 on a wait timeout and says the finalizer is still held" {
# The branch 2b47c5a5 added. Exiting non-zero here wedges the uninstall
# forever, because helm-controller retries it; exiting 0 lets the uninstall
# finish and leaves the topics for a human. Reverting that branch to `exit 1`
# turns this test red, which is the point: the helm-unittest suite does not
# notice.
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=1
export DELETE_OUT='error: timed out waiting for the condition on kafkatopics/kafka-test-orders'
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on a wait timeout, which wedges the uninstall"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q 'still hold the strimzi.io/topic-operator finalizer' "$tmp/err"; then
echo "FAIL: the degraded path said nothing about the finalizer"
cat "$tmp/err"
false
fi
if ! grep -q 'clear the finalizer by hand' "$tmp/err"; then
echo "FAIL: the degraded path did not say what to do about it"
cat "$tmp/err"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook exits non-zero on any other kubectl failure" {
# A Forbidden, an unreachable apiserver or a webhook rejection must NOT be
# read as the timeout case. Here the non-zero exit is the safety net: Helm
# stops before deleting the topic operator, so the topics keep something that
# can clear their finalizer.
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=1
export DELETE_OUT='Error from server (Forbidden): kafkatopics.kafka.strimzi.io is forbidden'
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -eq 0 ]; then
echo "FAIL: hook exited 0 on a Forbidden, so Helm would remove the topic operator"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q '^ERROR: deleting KafkaTopics failed' "$tmp/err"; then
echo "FAIL: the failure was not reported as an error"
cat "$tmp/err"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook deletes this release's node pools after its topics" {
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=0
export DELETE_OUT='kafkatopic.kafka.strimzi.io "t" deleted'
export POOL_RC=0
export POOL_OUT='kafkanodepool.kafka.strimzi.io "b-0" deleted'
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on a clean delete"
cat "$tmp/out" "$tmp/err"
false
fi
# Anchored like the topic selector above: the trailing space keeps the
# sibling release kafka-test2 out.
pool_line=$(grep -n -- "delete kafkanodepools.kafka.strimzi.io -l strimzi.io/cluster=kafka-test " "$tmp/calls" | cut -d: -f1)
topic_line=$(grep -n -- "delete kafkatopics.kafka.strimzi.io -l strimzi.io/cluster=kafka-test " "$tmp/calls" | cut -d: -f1)
if [ -z "$pool_line" ] || [ -z "$topic_line" ]; then
echo "FAIL: expected one release-scoped delete of each kind"
cat "$tmp/calls"
false
fi
# The topic operator clears topic finalizers through the brokers, so the
# brokers have to outlive the topic delete.
if [ "$pool_line" -le "$topic_line" ]; then
echo "FAIL: the node pools were deleted before the topics"
cat "$tmp/calls"
false
fi
if ! grep -q -- "kafkanodepools.* --wait=false" "$tmp/calls"; then
echo "FAIL: the node pool delete waits, which spends the Job's deadline"
cat "$tmp/calls"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook still deletes the node pools after a topic wait timeout" {
# The timeout branch lets the uninstall finish. Skipping the pools there
# would leave the brokers running with nothing left to remove them.
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=1
export DELETE_OUT='error: timed out waiting for the condition on kafkatopics/kafka-test-orders'
export POOL_RC=0
export POOL_OUT='kafkanodepool.kafka.strimzi.io "b-0" deleted'
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on a wait timeout"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q -- "delete kafkanodepools.kafka.strimzi.io -l strimzi.io/cluster=kafka-test " "$tmp/calls"; then
echo "FAIL: the node pools were skipped after the topic wait timed out"
cat "$tmp/calls"
false
fi
rm -rf "$tmp"
}
@test "kafka pre-delete hook exits 0 when the KafkaNodePool CRD is absent" {
for msg in 'error: the server doesn'"'"'t have a resource type "kafkanodepools"' \
'error: no matches for kind "KafkaNodePool" in version "kafka.strimzi.io/v1beta2"'; do
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=0
export DELETE_OUT='kafkatopic.kafka.strimzi.io "t" deleted'
export POOL_RC=1
export POOL_OUT="$msg"
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "FAIL: hook exited $rc on an absent KafkaNodePool CRD, wedging the uninstall"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q 'KafkaNodePool CRD absent' "$tmp/out"; then
echo "FAIL: the absent CRD was not reported as such"
cat "$tmp/out"
false
fi
rm -rf "$tmp"
done
}
@test "kafka pre-delete hook exits non-zero when the node pool delete fails" {
# Once the uninstall finishes, Helm never runs this hook for the release
# again, so a pool left here is left for good. A non-zero exit stops the
# uninstall before it deletes anything, and helm-controller retries it.
tmp=$(mktemp -d)
write_fake_kubectl "$tmp"
render_hook "$KAFKA_CHART" kafka-test tenant-test kafka-test-pre-delete "$tmp/hook.sh"
export KLOG="$tmp/calls"
export DELETE_RC=0
export DELETE_OUT='kafkatopic.kafka.strimzi.io "t" deleted'
export POOL_RC=1
export POOL_OUT='Error from server (Forbidden): kafkanodepools.kafka.strimzi.io is forbidden'
rc=0
PATH="$tmp:$PATH" sh "$tmp/hook.sh" > "$tmp/out" 2> "$tmp/err" || rc=$?
if [ "$rc" -eq 0 ]; then
echo "FAIL: hook exited 0 on a Forbidden, so the uninstall would finish and leave the pools"
cat "$tmp/out" "$tmp/err"
false
fi
if ! grep -q '^ERROR: deleting KafkaNodePools failed' "$tmp/err"; then
echo "FAIL: the failure was not reported as an error"
cat "$tmp/err"
false
fi
rm -rf "$tmp"
}