#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for the fail-closed contract of
# packages/core/platform/images/migrations/migrations/47
#
# Migration 47 pins the implicit default `md0` node group on existing tenant
# Kubernetes clusters before the kubernetes chart stops shipping a baked-in
# md0. Its safety property is the ENTIRE reason it exists: if any per-object
# read or patch fails, it must abort (exit 1) BEFORE stamping the
# cozystack-version ConfigMap, so the pre-upgrade hook fails and Helm never
# rolls the new chart against an un-pinned cluster (whose live md0
# MachineDeployment would otherwise be pruned โ CAPI Machine/node deletion).
#
# These tests pin that branch so it cannot silently regress to fail-open. We
# put a fake `kubectl` on PATH and assert that:
# - a non-NotFound read failure aborts non-zero and never reaches the stamp;
# - a patch failure aborts non-zero and never reaches the stamp;
# - a clean run DOES reach the stamp (positive control, so "no stamp" above
# is a real signal and not a script that simply never called kubectl).
# "The stamp ran" is detected by the version stamp's `kubectl apply` appearing
# in the fake's call log โ equivalently, CURRENT_VERSION was/was not advanced.
#
# CI runs this file under Bats through `make bats-unit-tests`. It also remains
# compatible with the legacy `hack/cozytest.sh` translator, whose awk parser
# recognizes only @test blocks and a bare `}` at column 0. The tests avoid
# `run`, `$status`, setup(), and teardown() for that compatibility path. Their
# assertions are direct shell tests, and the
# fake-kubectl heredocs deliberately keep every `}` off column 0 so the parser
# does not mistake one for the end of a test. Each @test runs in its own
# subshell, and cleanup is inline (no EXIT/RETURN trap โ see
# docs/agents/e2e-testing.md ยง3).
#
# Run with: bats hack/kubernetes-md0-migration.bats
# -----------------------------------------------------------------------------
load test_helper
HACK_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")" && pwd)"
REPO_ROOT="$(cd "$HACK_DIR/.." && pwd)"
MIGRATION="$REPO_ROOT/packages/core/platform/images/migrations/migrations/47"
# write_fake_kubectl
# Emits a fake `kubectl` that logs every call and behaves per $MOCK_FAIL:
# - the --all-namespaces list always returns one object (tenant-test/demo);
# - the per-object `get ... --output json` fails (MOCK_FAIL=read) or returns
# an object whose nodeGroups lacks md0 (so the migration tries to patch);
# - `patch` fails when MOCK_FAIL=patch, else succeeds;
# - `apply` (the version stamp) is a no-op that drains stdin.
# No line below is a bare column-0 `}`, so cozytest.sh's parser leaves it intact.
write_fake_kubectl() {
cat > "$1/kubectl" <<'KEOF'
#!/bin/sh
echo "$*" >> "$KLOG"
case "$*" in
*--all-namespaces*)
printf 'tenant-test\tdemo\n'
exit 0
;;
*"--output json"*)
if [ "${MOCK_FAIL:-}" = read ]; then
echo 'error: an error on the server ("timeout") has prevented the request from succeeding' >&2
exit 1
fi
printf '%s' '{"apiVersion":"apps.cozystack.io/v1alpha1","kind":"Kubernetes","metadata":{"namespace":"tenant-test","name":"demo"},"spec":{"nodeGroups":{"worker0":{"minReplicas":1,"maxReplicas":3}}}}'
exit 0
;;
*patch*)
if [ "${MOCK_FAIL:-}" = patch ]; then
echo 'error: kubernetes.apps.cozystack.io "demo" could not be patched (simulated)' >&2
exit 1
fi
cat >/dev/null
echo 'kubernetes.apps.cozystack.io/demo patched'
exit 0
;;
*apply*)
cat >/dev/null
exit 0
;;
*)
exit 0
;;
esac
KEOF
chmod +x "$1/kubectl"
}
@test "migration 47 aborts non-zero and skips the version stamp when a read fails" {
tmp=$(mktemp -d)
export KLOG="$tmp/kubectl.log"
: > "$KLOG"
write_fake_kubectl "$tmp" "$KLOG"
if KLOG="$KLOG" MOCK_FAIL=read PATH="$tmp:$PATH" "$MIGRATION" >"$tmp/out" 2>&1; then
echo "expected migration to exit non-zero on a non-NotFound read failure; output:" >&2
cat "$tmp/out" >&2
rm -rf "$tmp"
exit 1
fi
# The version stamp is `kubectl apply` of the cozystack-version ConfigMap.
# If it ran, the migration advanced CURRENT_VERSION despite a failure.
if grep -q apply "$KLOG"; then
echo "fail-open regression: version stamp (kubectl apply) ran despite a read failure" >&2
cat "$KLOG" >&2
rm -rf "$tmp"
exit 1
fi
rm -rf "$tmp"
}
@test "migration 47 aborts non-zero and skips the version stamp when a patch fails" {
tmp=$(mktemp -d)
export KLOG="$tmp/kubectl.log"
: > "$KLOG"
write_fake_kubectl "$tmp" "$KLOG"
if KLOG="$KLOG" MOCK_FAIL=patch PATH="$tmp:$PATH" "$MIGRATION" >"$tmp/out" 2>&1; then
echo "expected migration to exit non-zero on a patch failure; output:" >&2
cat "$tmp/out" >&2
rm -rf "$tmp"
exit 1
fi
if grep -q apply "$KLOG"; then
echo "fail-open regression: version stamp (kubectl apply) ran despite a patch failure" >&2
cat "$KLOG" >&2
rm -rf "$tmp"
exit 1
fi
rm -rf "$tmp"
}
@test "migration 47 stamps the version on a clean run (positive control)" {
tmp=$(mktemp -d)
export KLOG="$tmp/kubectl.log"
: > "$KLOG"
write_fake_kubectl "$tmp" "$KLOG"
# No MOCK_FAIL: read returns an md0-less object, patch succeeds, stamp runs.
if ! KLOG="$KLOG" MOCK_FAIL=none PATH="$tmp:$PATH" "$MIGRATION" >"$tmp/out" 2>&1; then
echo "expected a clean migration run to exit zero; output:" >&2
cat "$tmp/out" >&2
rm -rf "$tmp"
exit 1
fi
# Proves the stamp IS reachable here, so the two "no apply" assertions above
# are meaningful rather than vacuously true.
if ! grep -q apply "$KLOG"; then
echo "expected the version stamp (kubectl apply) to run on a clean migration" >&2
cat "$KLOG" >&2
rm -rf "$tmp"
exit 1
fi
rm -rf "$tmp"
}