#!/usr/bin/env bats # Behavioural unit test for the Kafka topic-metadata strategy driver script # (packages/system/backupstrategy-controller/files/kafka-backup.sh), the shell # embedded verbatim into strategy-kafka-default.yaml. # # The chart's helm-unittest suite can only assert the rendered Strategy CR's # shape (kind, name, artifactURITemplate); the driver script that does the # actual backup/restore/cleanup work is otherwise unpinned, so a regression in # it (a stripped \Q, a reinstated -k, a fail-open --list) passes the chart suite # untouched. This test closes that gap the way the contract is really defined: # it EXECUTES the script against stubbed kafka-topics.sh / kafka-configs.sh (BIN # override) and a stubbed curl (on PATH), and asserts what the script does. It # is not a template grep — a comment cannot satisfy it. # # Runner note: the repo's bats runner is hack/cozytest.sh, which knows only # @test + bash — no setup()/teardown() and no run/$status/$output. So each test # calls init_stubs itself, and assertions use plain command exit codes and # $(...) capture (set -e makes a failed command fail the test). # # Each named guard below corresponds to a mutation the script must not survive: # \Q literal-match on describe and alter, the --connect-to port workaround with # no -k (keyed off the request host in both addressing modes), the fail-closed # --list, the comma-value bracket grouping, the replication-factor divergence # guard, and the cleanup delete's HTTP handling. load test_helper SCRIPT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)/packages/system/backupstrategy-controller/files/kafka-backup.sh" # init_stubs builds a fresh workspace with stub kafka CLIs (the script's $BIN) # and a stub curl (first on PATH), and exports the common env. It sets STATE, # BINDIR, PATHDIR in the caller's shell — each @test runs in its own subshell, # so calling it first gives that test an isolated sandbox. init_stubs() { STATE="$(mktemp -d)" BINDIR="$STATE/bin" PATHDIR="$STATE/path" mkdir -p "$BINDIR" "$PATHDIR" # Per-test metadata scratch file so a run cannot read back records a prior # test left in the script's default /tmp/kafka-metadata.txt. export METADATA_FILE="$STATE/metadata.txt" cat > "$BINDIR/kafka-topics.sh" </dev/null || true else cat "$STATE/topics" 2>/dev/null || true fi exit 0 fi if [ "\$mode" = describe ]; then echo "\$topic" >> "$STATE/describe_args" name="\${topic#'\\Q'}"; name="\${name%'\\E'}" if [ "\$name" = "\$topic" ]; then # No \\Q...\\E wrapping: --topic is a Java regex, so emit every stored topic # whose name matches it as an ERE (the collision a stripped \\Q reintroduces). hit=0 for f in "$STATE"/desc.*; do [ -e "\$f" ] || continue n="\${f##*/desc.}" if printf '%s\n' "\$n" | grep -qE "^\${name}\$"; then cat "\$f"; hit=1; fi done [ "\$hit" = 1 ] || exit 1 exit 0 fi [ -f "$STATE/desc.\$name" ] || exit 1 cat "$STATE/desc.\$name" exit 0 fi if [ "\$mode" = create ]; then echo "create \$topic \$parts" >> "$STATE/actions"; exit 0; fi if [ "\$mode" = alter ]; then echo "alter \$topic \$parts" >> "$STATE/actions"; exit 0; fi exit 0 STUB cat > "$BINDIR/kafka-configs.sh" </dev/null || true; exit 0 fi if [ "\$mode" = alter ]; then echo "\$name \$add" >> "$STATE/configs_applied"; exit 0; fi exit 0 STUB # curl stub: records argv, simulates a tiny S3 (PUT stores the body, GET serves # it back), and prints an HTTP code for the -w cleanup DELETE. cat > "$PATHDIR/curl" <> "$STATE/curl_args" up=""; out=""; wfmt=""; prev=""; hasf="" for a in "\$@"; do case "\$prev" in --upload-file) up="\$a" ;; -o) out="\$a" ;; -w) wfmt="\$a" ;; esac case "\$a" in -fsS|-f) hasf=1 ;; esac prev="\$a" done if [ -n "\$wfmt" ]; then echo "\${DELETE_CODE:-204}"; exit 0; fi if [ -n "\$up" ]; then # PUT_FAIL simulates a 403 on upload: with -f curl errors (exit 22) and stores # nothing; without -f it would exit 0 and a bad backup would report success. if [ -n "\${PUT_FAIL:-}" ]; then [ -n "\$hasf" ] && exit 22 exit 0 fi cp "\$up" "$STATE/s3_object"; exit 0 fi if [ -n "\$out" ]; then [ -f "$STATE/s3_object" ] || exit 22 cp "$STATE/s3_object" "\$out"; exit 0 fi exit 0 STUB chmod +x "$BINDIR"/*.sh "$PATHDIR/curl" export BIN="$BINDIR" export PATH="$PATHDIR:$PATH" export BOOTSTRAP="kafka-x-kafka-bootstrap.tenant-root.svc:9092" export S3_REGION="us-east-1" export S3_FORCE_PATH_STYLE="true" export AWS_ACCESS_KEY_ID="k" export AWS_SECRET_ACCESS_KEY="s" export ARTIFACT_URI="s3://bkt/ns/app/run/kafka-metadata.txt" } seed_three() { printf 'orders\naudit.events\naudit-events\n' > "$STATE/topics" printf 'Topic: orders\tPartitionCount: 3\tReplicationFactor: 1\n' > "$STATE/desc.orders" printf 'Topic: audit.events\tPartitionCount: 2\tReplicationFactor: 1\n' > "$STATE/desc.audit.events" printf 'Topic: audit-events\tPartitionCount: 5\tReplicationFactor: 1\n' > "$STATE/desc.audit-events" printf ' retention.ms=1234567890000 sensitive=false\n' > "$STATE/cfg.orders" } # expect_fail : succeeds when the command exits non-zero. expect_fail() { if "$@" >/dev/null 2>&1; then echo "expected a non-zero exit, got success" >&2; return 1 fi return 0 } # ---- backup --------------------------------------------------------------- @test "backup records each colliding topic's own partition count (literal Q match)" { init_stubs; seed_three MODE=backup S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" # The uploaded object must carry each topic's true partition count. A stripped # \Q makes the describe of audit.events match audit-events too, taking the # wrong first line — this assertion goes red then. grep -qxF "$(printf 'T\torders\t3\t1')" "$STATE/s3_object" grep -qxF "$(printf 'T\taudit.events\t2\t1')" "$STATE/s3_object" grep -qxF "$(printf 'T\taudit-events\t5\t1')" "$STATE/s3_object" # And the describe was invoked with the literal \Q...\E wrapper. grep -qxF '\Qaudit.events\E' "$STATE/describe_args" } @test "backup fails closed when --list errors (no empty backup as success)" { init_stubs; seed_three expect_fail env MODE=backup LIST_FAIL=1 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/s3_object" ] } @test "backup stores a comma-valued config in its own tab field" { init_stubs printf 'orders\n' > "$STATE/topics" printf 'Topic: orders\tPartitionCount: 1\tReplicationFactor: 1\n' > "$STATE/desc.orders" printf ' cleanup.policy=compact,delete sensitive=false\n' > "$STATE/cfg.orders" MODE=backup S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" grep -qxF "$(printf 'C\torders\tcleanup.policy\tcompact,delete')" "$STATE/s3_object" } # ---- S3 signing (curl 7.76.1 port workaround, no -k) ----------------------- @test "ported endpoint signs with --connect-to and never -k" { init_stubs; seed_three MODE=backup S3_ENDPOINT="https://s3.example.org:8333" bash "$SCRIPT" grep -qF -- '--connect-to s3.example.org:443:s3.example.org:8333' "$STATE/curl_args" # Positive form, not `! grep`: hack/cozytest.sh wraps each @test in `set -e` # + a trailing `return 0`, and bash exempts a `!`-negated command from errexit, # so a `! grep` guard can never fail under the runner CI uses (only real bats). if grep -qE -- '(^| )-k( |$)' "$STATE/curl_args"; then echo "regression: -k (insecure TLS) in curl argv: $(cat "$STATE/curl_args")" >&2; return 1 fi } @test "default-port endpoint needs no --connect-to" { init_stubs; seed_three MODE=backup S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" # Positive form (see the -k note above): a `! grep` is vacuous under cozytest. if grep -qF -- '--connect-to' "$STATE/curl_args"; then echo "unexpected --connect-to on a default-port endpoint: $(cat "$STATE/curl_args")" >&2; return 1 fi } @test "virtual-hosted ported endpoint keys --connect-to off the bucket host" { init_stubs; seed_three MODE=backup S3_FORCE_PATH_STYLE=false S3_ENDPOINT="https://s3.example.org:8333" bash "$SCRIPT" # HOST1 must be the request host (bkt.s3.example.org), else curl never # redirects and the request hits the wrong port. grep -qF -- '--connect-to bkt.s3.example.org:443:s3.example.org:8333' "$STATE/curl_args" grep -qF 'https://bkt.s3.example.org/ns/app/run/kafka-metadata.txt' "$STATE/curl_args" } # ---- restore -------------------------------------------------------------- write_backup_object() { printf 'T\torders\t3\t1\nC\torders\tcleanup.policy\tcompact,delete\n' > "$STATE/s3_object" } @test "restore of an absent topic creates it and brackets a comma-valued config" { init_stubs; write_backup_object : > "$STATE/topics" # nothing live -> create path MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" grep -qxF "create orders 3" "$STATE/actions" # comma value must be bracket-grouped so kafka-configs does not split it. grep -qxF "orders cleanup.policy=[compact,delete]" "$STATE/configs_applied" } @test "restore fails loudly on a replication-factor mismatch" { init_stubs printf 'T\torders\t3\t2\n' > "$STATE/s3_object" # backup wants RF 2 printf 'orders\n' > "$STATE/topics" # live exists printf 'Topic: orders\tPartitionCount: 3\tReplicationFactor: 1\n' > "$STATE/desc.orders" out="" if out=$(MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" 2>&1); then echo "restore unexpectedly succeeded: $out" >&2; return 1 fi printf '%s\n' "$out" | grep -q "replication factor differs" } @test "restore aborts when the broker is unreachable (fail-closed --list)" { init_stubs; write_backup_object expect_fail env MODE=restore LIST_FAIL=1 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" # Must not have routed the unreachable broker into --create. [ ! -f "$STATE/actions" ] } @test "restore of an existing under-partitioned topic alters it with the literal-Q wrapper" { init_stubs printf 'T\tpay.events\t7\t1\n' > "$STATE/s3_object" # backup wants 7 partitions printf 'pay.events\npay-events\n' > "$STATE/topics" # both live -> the collision printf 'Topic: pay.events\tPartitionCount: 2\tReplicationFactor: 1\n' > "$STATE/desc.pay.events" # The colliding sibling is given a distinct, larger partition count so the two # describe/alter \Q guards are pinned independently: a stripped \Q on the # restore --describe (line 166) matches this sibling first (glob-order # `desc.pay-events` precedes `desc.pay.events`), reads 9 > the backup's 7, and # aborts with "cannot decrease partitions" — no alter is recorded, so the # assertion below reddens. printf 'Topic: pay-events\tPartitionCount: 9\tReplicationFactor: 1\n' > "$STATE/desc.pay-events" MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" # The alter must target the literal topic (\Q...\E). A stripped wrapper on the # --describe misroutes to the sibling (above); a stripped wrapper on the --alter # sends a regex that re-partitions the colliding sibling permanently — the # destructive bug this guards. Dropping \Q on either call reddens here. grep -qxF 'alter \Qpay.events\E 7' "$STATE/actions" } @test "restore refuses to shrink partitions" { init_stubs printf 'T\torders\t2\t1\n' > "$STATE/s3_object" # backup wants 2 printf 'orders\n' > "$STATE/topics" printf 'Topic: orders\tPartitionCount: 5\tReplicationFactor: 1\n' > "$STATE/desc.orders" # live 5 > 2 out="" if out=$(MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" 2>&1); then echo "restore unexpectedly succeeded: $out" >&2; return 1 fi printf '%s\n' "$out" | grep -q 'Kafka cannot decrease partitions' } @test "backup fails closed when the S3 upload is rejected (curl -f)" { init_stubs; seed_three # PUT_FAIL simulates a 403; -fsS must turn that into a non-zero exit so the # backup does not report success against an object that never landed. expect_fail env MODE=backup PUT_FAIL=1 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/s3_object" ] } @test "backup excludes Kafka internal topics" { init_stubs printf 'orders\n__consumer_offsets\n' > "$STATE/topics" printf 'Topic: orders\tPartitionCount: 3\tReplicationFactor: 1\n' > "$STATE/desc.orders" # No desc.__consumer_offsets on purpose: with --exclude-internal it is filtered # from --list; drop the flag and the backup instead tries to describe it and # fails — either way it must never land in the object. MODE=backup S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" grep -qxF "$(printf 'T\torders\t3\t1')" "$STATE/s3_object" if grep -qF '__consumer_offsets' "$STATE/s3_object"; then echo "internal topic captured" >&2; return 1; fi } # ---- cleanup -------------------------------------------------------------- @test "cleanup treats 204 as success" { init_stubs MODE=cleanup DELETE_CODE=204 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" } @test "cleanup treats a 500 as a retryable failure" { init_stubs expect_fail env MODE=cleanup DELETE_CODE=500 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" } @test "cleanup treats a 404 as success (idempotent delete)" { init_stubs # Dropping 404 from the success case routes it to the `*)` failure arm; this # would then requeue the delete forever against an already-gone object. MODE=cleanup DELETE_CODE=404 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" } # ---- fail-closed guards (each reddens when its guard is removed) ----------- @test "unknown MODE fails closed instead of running the restore branch" { init_stubs; write_backup_object : > "$STATE/topics" # internal/template returns a leaf unchanged when its render fails, so an # unrendered {{ .Mode }} reaches the pod literally; without the MODE case the # bare `else` would run the (mutating) restore branch during a backup. With a # valid object seeded, dropping the guard makes the script exit 0 here. expect_fail env MODE='{{ .Mode }}' S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/actions" ] } @test "non-s3 ARTIFACT_URI fails closed" { init_stubs; seed_three # Without the s3:// prefix guard the `${ARTIFACT_URI#s3://}` split produces a # garbage bucket/key and the backup uploads to the wrong place, exiting 0. expect_fail env MODE=backup ARTIFACT_URI="https://bkt/ns/app/run/x.txt" S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" } @test "backup fails closed on an unparsable topic describe" { init_stubs printf 'orders\n' > "$STATE/topics" printf 'Topic: orders no-partition-count-here\n' > "$STATE/desc.orders" # Empty parts/rf must abort, not write the record `T\torders\t\t` that a later # restore feeds to `--partitions ""`. expect_fail env MODE=backup S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/s3_object" ] } @test "backup fails closed when kafka-configs --describe errors" { init_stubs printf 'orders\n' > "$STATE/topics" printf 'Topic: orders\tPartitionCount: 1\tReplicationFactor: 1\n' > "$STATE/desc.orders" # A configs describe that errors (not "no non-default configs") must abort, not # silently drop the topic's configs and report success. expect_fail env MODE=backup CFG_FAIL=1 S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/s3_object" ] } @test "restore fails closed on an unparsable live describe" { init_stubs printf 'T\torders\t3\t1\n' > "$STATE/s3_object" # backup wants 3 partitions, RF 1 printf 'orders\n' > "$STATE/topics" # live exists printf 'Topic: orders\tReplicationFactor: 1\n' > "$STATE/desc.orders" # RF matches, no PartitionCount # An unparsable live describe must abort the restore, not report the topic # restored without altering it. (set -euo pipefail already aborts at the # `lp=$(describe_field ...)` assignment; the explicit guard is belt-and-braces.) expect_fail env MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/actions" ] } @test "restore fails closed on a non-numeric recorded partition count" { init_stubs printf 'T\torders\tabc\t1\n' > "$STATE/s3_object" # recorded parts is not a number printf 'orders\n' > "$STATE/topics" # live exists at 3 partitions printf 'Topic: orders\tPartitionCount: 3\tReplicationFactor: 1\n' > "$STATE/desc.orders" # `[ 3 -lt abc ]` / `[ 3 -gt abc ]` exit 2 and an `if` condition is exempt from # errexit, so without the recorded-shape guard the topic is skipped yet the # script prints its success line and exits 0. expect_fail env MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/actions" ] } @test "restore fails closed on a non-numeric recorded replication factor" { init_stubs printf 'T\torders\t3\tabc\n' > "$STATE/s3_object" # recorded rf is not a number : > "$STATE/topics" # topic absent -> restore takes the --create path # Recorded partitions are valid, so the parts guard passes and the create path # hands rf straight to `--replication-factor`. Without the recorded-rf guard a # non-numeric rf reaches kafka-topics --create and the topic is (mis)created # rather than the restore failing loudly on a wrong-content object. expect_fail env MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/actions" ] } @test "restore fails closed on an unrecognised record kind" { init_stubs printf 'X\torders\t3\t1\n' > "$STATE/s3_object" # neither T nor C : > "$STATE/topics" # A well-formed but wrong-content object (curl -f catches transport errors, not # a wrong-content 200) must not restore a subset and report success. expect_fail env MODE=restore S3_ENDPOINT="https://s3.example.org" bash "$SCRIPT" [ ! -f "$STATE/actions" ] }