hack/migration-54-redis-adopt.bats
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for platform migration 54 --> 55 (adopt every existing
# RedisFailover onto the freshworks-oss redis-operator's group filter AND pin
# its engine, before the new operator starts).
#
# redis-operator v3.3.5 watches RedisFailovers through a server-side label
# selector (redis-failover.freshworks.com/operator-group=cozystack); a CR
# without that label never reaches the operator's informer. The same window is
# dangerous for the engine version: v3.3.5's compiled-in default moved
# 6.2.6 -> 7.2.4, which rewrites dump.rdb into a format 6.2.6 cannot load. So the
# migration, in one pass ahead of the new operator, ALWAYS stamps the label and
# pins spec.redis.image / spec.sentinel.image to redis:6.2.6-alpine ONLY where
# that field is empty — leaving a preset image (apps/redis pins its data image
# via versionMap) untouched.
#
# Three properties are pinned here:
#
# 1. FAIL CLOSED ON THE PROBE. `kubectl get crd ... --ignore-not-found -o name`
# separates a genuinely absent CRD (rc 0, empty -> nothing to adopt, stamp
# and exit) from a real apiserver error (rc != 0 -> abort under
# set -euo pipefail). A one-off blip read as "absent" would stamp the
# version and leave the whole fleet unlabelled and invisible to the new
# operator, permanently — the exact failure this migration exists to
# prevent.
#
# 2. LABEL ALWAYS, PIN ONLY WHERE EMPTY. Every adopted CR's patch carries the
# operator-group label. The engine pin is added only for a role whose image
# is empty: a both-empty CR (Harbor) gets both images pinned; a
# sentinel-only-empty CR (apps/redis, redis.image preset) gets ONLY the
# sentinel pinned and its preset redis.image is never overwritten; a
# fully-preset CR gets the label and no pin at all.
#
# 3. FAIL CLOSED ON THE PATCH. Migrations never re-run, so a swallowed patch
# error would stamp the version on a half-adopted fleet and never look back.
# A failed patch must abort BEFORE stamping so the Job retries.
#
# These drive the real migration script end-to-end against a fake kubectl
# (hack/testdata/migration-54-redis/), mocking only the cluster boundary.
#
# SHELL. Production runs migration 54 under /bin/sh = busybox ash: the migrations
# image is FROM alpine and run-migrations.sh execs `/migrations/<n>` BY PATH, so
# the kernel honours the `#!/bin/sh` shebang. `set -euo pipefail` and its
# errexit semantics differ from bash, and pipefail is load-bearing: the fleet
# scan pipes `kubectl ... -o json` into jq. So run_migration() runs the script by
# path inside the image's own pinned base rather than through a host shell — same
# base image, same interpreter, same invocation form.
#
# jq. Migration 54 pipes through jq (fleet scan, per-CR image read, patch
# construction). The raw alpine base ships without it while the built migrations
# image installs it via `apk add`, so prep() bakes jq onto the pinned base once
# (the only step that touches the network) and run_migration() uses that image,
# still --network none.
#
# 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
# rewrites a bare `}` into `return 0` plus `}`. The tests avoid `run`, `$status`,
# and setup() for that path. A helper whose status matters captures and returns
# it before its closing brace so the injected return cannot mask it.
#
# Run with: bats hack/migration-54-redis-adopt.bats
# -----------------------------------------------------------------------------
load test_helper
FAKEBIN="$PWD/hack/testdata/migration-54-redis"
MIG_DIR="$PWD/packages/core/platform/images/migrations/migrations"
# The production base image, read out of the migrations Dockerfile rather than
# repeated here, so the interpreter under test cannot drift from the one the
# migrations actually ship on when that pin is bumped.
ALPINE=$(sed -n 's/^FROM \(alpine:[^ ]*\).*$/\1/p' \
"$PWD/packages/core/platform/images/migrations/Dockerfile" | head -1)
# jq-enabled build of that base. The tag is derived from the pinned ref so a
# digest bump rebuilds it instead of reusing a stale layer.
TESTIMG="cozystack-migration54-test:$(printf '%s' "$ALPINE" | sed 's/[^a-zA-Z0-9]/-/g')"
# run_migration <n> -- run migrations/<n> the way run-migrations.sh does.
#
# By path, not `sh <file>`: that is what makes the shebang, and therefore the
# interpreter, part of what is under test. The fake kubectl goes on PATH inside
# the container and $WORK is bind-mounted, so $FAKE_CMDLOG is the same file the
# assertions read back on the host. --network none because nothing here may reach
# a real cluster; --user keeps $WORK removable by the test afterwards.
#
# The explicit `return` is load-bearing: cozytest.sh's awk generator rewrites
# every bare `}` in column 0 into `return 0` + `}`, so a helper that falls off
# its own end returns 0 no matter what it ran, and every fail-closed assertion
# below would pass vacuously. Capture the status and return it by hand.
run_migration() {
_run_migration_rc=0
docker run --rm --network none \
--user "$(id -u):$(id -g)" \
-v "$MIG_DIR:/migrations:ro" \
-v "$FAKEBIN:/fakebin:ro" \
-v "$WORK:/work" \
-e PATH=/fakebin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
-e FAKE_CMDLOG=/work/cmdlog \
-e NAMESPACE="${NAMESPACE-}" \
-e FAKE_RFS="${FAKE_RFS-}" \
-e FAKE_CRD_ABSENT="${FAKE_CRD_ABSENT-}" \
-e FAKE_CRD_PROBE_FAIL="${FAKE_CRD_PROBE_FAIL-}" \
-e FAKE_LABEL_FAIL="${FAKE_LABEL_FAIL-}" \
"$TESTIMG" "/migrations/$1" || _run_migration_rc=$?
return "$_run_migration_rc"
}
# prep resets env to a clean scenario. Tests set FAKE_* afterwards.
prep() {
# Fail here rather than at the first docker run, so the reason is legible.
docker info >/dev/null 2>&1 || {
echo "docker is required: these tests run migration 54 inside a jq-enabled" >&2
echo "build of $ALPINE (the migrations image's base), so that it exercises" >&2
echo "busybox ash — the interpreter run-migrations.sh actually gives it." >&2
return 1
}
# Bake jq onto the pinned base. Cached after the first run, so this is a no-op
# on every subsequent test; only the first build touches the network.
docker build -q -t "$TESTIMG" - >/dev/null <<DOCKERFILE
FROM $ALPINE
RUN apk add --no-cache jq
DOCKERFILE
chmod +x "$FAKEBIN/kubectl"
WORK=$(mktemp -d)
export FAKE_CMDLOG="$WORK/cmdlog"
: > "$FAKE_CMDLOG"
export NAMESPACE=cozy-system
export FAKE_RFS=""
unset FAKE_CRD_ABSENT FAKE_CRD_PROBE_FAIL FAKE_LABEL_FAIL || true
return 0
}
# patch_for <ns> <name> -- the merge-patch payload the migration sent for that
# CR (the JSON tail of its `PATCH <ns> <name> <json>` cmdlog line). Used only for
# its stdout, so the awk-injected `return 0` is harmless.
patch_for() {
grep -E "^PATCH $1 $2 " "$FAKE_CMDLOG" | head -1 | cut -d' ' -f4-
}
# --- 1. fail closed on the probe --------------------------------------------
@test "absent CRD stamps 55 without patching anything" {
prep
export FAKE_CRD_ABSENT=1
rc=0
run_migration 54 >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"; cat "$FAKE_CMDLOG"
[ "$rc" -eq 0 ]
# Nothing to adopt: no patch was ever issued...
[ "$(grep -cE -- '^PATCH ' "$FAKE_CMDLOG")" -eq 0 ]
# ...but the version is stamped to 55 so the runner advances. Asserting the
# number, not a bare "STAMP": a wrong version would loop run-migrations.sh.
grep -qF -- "STAMP 55" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
# A real apiserver error on the CRD probe is NOT "absent". The migration must
# abort under set -euo pipefail before stamping, so the Job retries rather than
# stamping past an un-probed, un-adopted fleet.
@test "a failing CRD probe aborts before stamping" {
prep
export FAKE_CRD_PROBE_FAIL=1
rc=0
run_migration 54 >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"; cat "$FAKE_CMDLOG"
# Must propagate: a swallowed probe error would stamp past the whole fleet.
[ "$rc" -ne 0 ]
# Nothing was patched and nothing was stamped.
[ "$(grep -cE -- '^PATCH ' "$FAKE_CMDLOG")" -eq 0 ]
[ "$(grep -cF -- 'STAMP 55' "$FAKE_CMDLOG")" -eq 0 ]
rm -rf "$WORK"
}
# --- 2. label always, pin only where empty ----------------------------------
@test "adopts every RedisFailover across namespaces: label always, engine pin only where empty" {
prep
# Three CRs in three namespaces exercising each image shape:
# harbor both images empty (Harbor) -> pin BOTH
# my-redis redis preset, sentinel empty -> pin ONLY sentinel
# full-redis both images preset -> label only, no pin
export FAKE_RFS="tenant-harbor harbor - -
tenant-redis my-redis redis:8.4.0 -
tenant-full full-redis redis:7.0.0 redis:7.2.0"
rc=0
run_migration 54 >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"; cat "$FAKE_CMDLOG"
[ "$rc" -eq 0 ]
# Exactly the three CRs were patched, no more.
[ "$(grep -cE '^PATCH ' "$FAKE_CMDLOG")" -eq 3 ]
# Every patch carries the operator-group label (adoption is unconditional).
# Scope the count to PATCH lines: the fake also logs a raw KUBECTL line per
# call, so the label string appears twice per patch across the whole cmdlog.
[ "$(grep -E '^PATCH ' "$FAKE_CMDLOG" | grep -cF -- '"redis-failover.freshworks.com/operator-group":"cozystack"')" -eq 3 ]
# Harbor: both images empty -> both pinned to redis:6.2.6-alpine, plus label.
ph=$(patch_for tenant-harbor harbor)
echo "$ph" | grep -qF -- '"redis-failover.freshworks.com/operator-group":"cozystack"'
echo "$ph" | grep -qF -- '"redis":{"image":"redis:6.2.6-alpine"}'
echo "$ph" | grep -qF -- '"sentinel":{"image":"redis:6.2.6-alpine"}'
# apps/redis: redis.image preset, sentinel empty -> ONLY sentinel pinned. The
# preset redis.image is neither overwritten nor even mentioned (a merge patch
# only carries what changes), so there is no redis node in the payload at all.
pr=$(patch_for tenant-redis my-redis)
echo "$pr" | grep -qF -- '"redis-failover.freshworks.com/operator-group":"cozystack"'
echo "$pr" | grep -qF -- '"sentinel":{"image":"redis:6.2.6-alpine"}'
[ "$(printf '%s' "$pr" | grep -cF -- '"redis":')" -eq 0 ]
[ "$(printf '%s' "$pr" | grep -cF -- 'redis:8.4.0')" -eq 0 ]
# Fully-preset: label only. Neither image is empty, so no engine pin and no
# spec node at all.
pf=$(patch_for tenant-full full-redis)
echo "$pf" | grep -qF -- '"redis-failover.freshworks.com/operator-group":"cozystack"'
[ "$(printf '%s' "$pf" | grep -cF -- 'redis:6.2.6-alpine')" -eq 0 ]
[ "$(printf '%s' "$pf" | grep -cF -- '"spec":')" -eq 0 ]
# Version stamped to 55 once, after adoption.
grep -qF -- "STAMP 55" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
# --- 3. fail closed on the patch --------------------------------------------
# The adoption patch fails. Applying the label/pin is the whole point of the
# migration, so a failed patch must abort BEFORE stamping — recoverable on the
# Job's next attempt, rather than a fleet stamped past adoption and left
# invisible to the new operator forever.
@test "a failed adoption patch aborts before stamping" {
prep
export FAKE_RFS="tenant-harbor harbor - -
tenant-redis my-redis redis:8.4.0 -"
export FAKE_LABEL_FAIL=1
rc=0
run_migration 54 >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"; cat "$FAKE_CMDLOG"
# Must propagate: a swallowed patch error would stamp a half-adopted fleet.
[ "$rc" -ne 0 ]
# And it must not have stamped the version.
[ "$(grep -cF -- 'STAMP 55' "$FAKE_CMDLOG")" -eq 0 ]
rm -rf "$WORK"
}