#!/usr/bin/env bats # ----------------------------------------------------------------------------- # Unit tests for platform migration 50 (adopt legacy etcd.aenix.io/v1alpha1 # clusters onto etcd-operator.cozystack.io/v1alpha2 via etcd-migrate). # # These drive the real migration script end-to-end against a fake kubectl and a # fake etcd-migrate (hack/testdata/migration-50/), mocking only the cluster # boundary — the same approach test/check-readiness uses. Every fake invocation # is logged so we can assert on the ordering and arguments of the destructive # steps, which is exactly the contract that cannot be checked by reading the # script. # # The behaviours pinned here are the review blockers: # 1. the safety snapshot is ALWAYS taken to the platform cozy-backups bucket — # never skipped — and the script fails loudly if the target is unresolvable; # 2. the staged credentials Secret is tenant-invisible (managed-by set, # tenantresource stripped); and # 3. the operator is scaled to 0 BEFORE etcd-migrate --apply, with --apply # always carrying the S3 backup destination, and stays at 0 after a # successful adoption. Only a failed adoption restores it to 1. # # 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 `}` on its own line. The tests avoid # `run`, `$status`, and setup() for that compatibility path. # # Run with: bats hack/migration-50-etcd-adopt.bats # ----------------------------------------------------------------------------- load test_helper FAKEBIN="$PWD/hack/testdata/migration-50" MIG="$PWD/packages/core/platform/images/migrations/migrations/50" # prep resets PATH/env to a clean scenario (one legacy cluster, a resolvable # platform target). Tests override the FAKE_* knobs afterwards. prep() { chmod +x "$FAKEBIN/kubectl" "$FAKEBIN/etcd-migrate" WORK=$(mktemp -d) export FAKE_CMDLOG="$WORK/cmdlog" export FAKE_STAGE_DIR="$WORK/staged" mkdir -p "$FAKE_STAGE_DIR" : > "$FAKE_CMDLOG" export PATH="$FAKEBIN:$PATH" export NAMESPACE=cozy-system export ETCD_OPERATOR_NS=cozy-etcd-operator export ETCD_OPERATOR_DEPLOY=etcd-operator-controller-manager export DRY_RUN=0 export CLUSTER_DOMAIN=cozy.local export PLATFORM_CREDS_NS=cozy-velero export FAKE_LEGACY_CRD=1 export FAKE_CLUSTERS="tenant-foo etcd" export FAKE_STRATEGY=1 export FAKE_CREDS=1 export FAKE_V2_CRD=0 export FAKE_CLAIM_CRD=1 # One healthy claim provisioned into the bucket the projected creds advertise: # the COSI path. Tests override this to model external S3 / stale claims. export FAKE_CLAIMS="tenant-root bucket-cozy-backups cozy-backups-7f3a -" # The baked-CRD dir must exist for the script's `-d` guard; the fake kubectl # logs the apply without reading it, so an empty dir is enough. export ETCD_CRD_DIR="$WORK/etcd-operator-crds" mkdir -p "$ETCD_CRD_DIR" unset ETCD_MIGRATE_BACKUP_ARGS || true } # lineno echoes the 1-based line number of the first $FAKE_CMDLOG line that # contains the fixed string $1 (empty if absent). lineno() { grep -nF -- "$1" "$FAKE_CMDLOG" | head -1 | cut -d: -f1 } @test "platform path auto-derives the S3 snapshot args and stages tenant-safe creds before adopting" { prep rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # --apply carries the platform-derived S3 destination, NOT --skip-backup. # The endpoint is derived from the projected Secret's bare host and forced to # https, NOT taken from the Strategy CR — the CR is rendered by the version we # are upgrading FROM and its plaintext in-cluster default cannot be reached. apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" if echo "$apply" | grep -qF -- "seaweedfs-s3.tenant-root.svc.cozy.local:8333"; then echo "FAIL: --apply must not carry the in-cluster S3 endpoint"; false; fi # https is forced because a BucketClaim is provisioned into exactly the bucket # the creds advertise, not because the projected endpoint happens to exist. grep -qF -- "BUCKETCLAIM-LIST" "$FAKE_CMDLOG" echo "$apply" | grep -qF -- "--backup-s3-bucket=cozy-backups-7f3a" echo "$apply" | grep -qF -- "--backup-s3-credentials-secret=cozy-backups-creds" echo "$apply" | grep -qF -- "--backup-s3-region=us-east-1" echo "$apply" | grep -qF -- "--backup-s3-force-path-style" # snapshots land under a system key prefix, not a tenant // path. echo "$apply" | grep -qF -- "--backup-s3-key=cozy-system/etcd-adoption/" if echo "$apply" | grep -qF -- "--skip-backup"; then echo "FAIL: --apply must not carry --skip-backup"; false; fi # --agent-image is explicit (the scaled-down Deployment is still the legacy operator). echo "$apply" | grep -qF -- "--agent-image=ghcr.io/cozystack/etcd-operator:v0.5.2" # The snapshot credentials are staged into the adopted namespace, and the # staged Secret is tenant-invisible (managed-by set, tenantresource stripped) # while still carrying the AWS_* keys etcd-migrate needs. grep -qF -- "STAGE tenant-foo" "$FAKE_CMDLOG" staged="$FAKE_STAGE_DIR/tenant-foo.json" [ -s "$staged" ] [ "$(jq -r '.metadata.labels["apps.cozystack.io/managed-by"]' "$staged")" = "cozystack-backups" ] [ "$(jq -r '.metadata.labels["internal.cozystack.io/tenantresource"] // "absent"' "$staged")" = "absent" ] [ "$(jq -r '.data.AWS_ACCESS_KEY_ID' "$staged")" = "QUtJQUVYQU1QTEU=" ] [ "$(jq -r '.metadata.namespace // "absent"' "$staged")" = "absent" ] # Steps run in this order, stage creds, scale the operator down, --apply, stamp. s_stage=$(lineno "STAGE tenant-foo") s_down=$(lineno "SCALE 0") s_apply=$(lineno "ETCD-MIGRATE --apply") s_stamp=$(lineno "STAMP") [ -n "$s_stage" ] && [ -n "$s_down" ] && [ -n "$s_apply" ] && [ -n "$s_stamp" ] [ "$s_stage" -lt "$s_down" ] [ "$s_down" -lt "$s_apply" ] [ "$s_apply" -lt "$s_stamp" ] rm -rf "$WORK" } @test "successful adoption leaves the legacy operator at 0 for the v1alpha2 operator to replace" { prep rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # A legacy operator back at 1 takes etcd-headless away from the adopted # members, see the success path of migration 50. grep -qF -- "SCALE 0" "$FAKE_CMDLOG" if grep -qF -- "SCALE 1" "$FAKE_CMDLOG"; then echo "FAIL: the legacy operator was scaled back up after a successful adoption"; false; fi grep -qF -- "STAMP" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "applies the baked v1alpha2 CRDs before adopting when they are absent" { prep export FAKE_V2_CRD=0 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # CRDs applied, and BEFORE etcd-migrate (which lists v1alpha2 clusters). grep -qF -- "APPLY-CRDS" "$FAKE_CMDLOG" s_crds=$(lineno "APPLY-CRDS") s_apply=$(lineno "ETCD-MIGRATE --apply") [ -n "$s_crds" ] && [ -n "$s_apply" ] [ "$s_crds" -lt "$s_apply" ] } @test "does not re-apply v1alpha2 CRDs when already installed" { prep export FAKE_V2_CRD=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] if grep -qF -- "APPLY-CRDS" "$FAKE_CMDLOG"; then echo "FAIL: no CRDs must have been applied"; false; fi # Adoption still proceeds. grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "re-issues server and peer certs with the native wildcard SAN before adopting" { prep # Certificates exist but lack the operator's native wildcard (the legacy # enumerated-SAN situation ensure_wildcard_sans exists to fix). export FAKE_CERTS=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # Both server and peer Certificates are patched... grep -qF -- "PATCH-CERT etcd-server" "$FAKE_CMDLOG" grep -qF -- "PATCH-CERT etcd-peer" "$FAKE_CMDLOG" # ...and the patch payload carries the native wildcard SAN. grep -F -- "patch certificate.cert-manager.io etcd-server" "$FAKE_CMDLOG" | grep -qF -- "*.etcd.tenant-foo.svc" # The re-issue happens BEFORE adoption (so a replacement member never fails TLS). s_patch=$(lineno "PATCH-CERT etcd-server") s_apply=$(lineno "ETCD-MIGRATE --apply") [ -n "$s_patch" ] && [ -n "$s_apply" ] [ "$s_patch" -lt "$s_apply" ] rm -rf "$WORK" } @test "failed adoption restores the operator to 1 replica (no stranded scale-to-0)" { prep export FAKE_MIGRATE_FAIL=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" # The migration must propagate the failure... [ "$rc" -ne 0 ] # ...but the EXIT trap must have scaled the cluster-wide operator back to 1 # AFTER the failed --apply, so reconciliation is not frozen platform-wide. s_down=$(lineno "SCALE 0") s_apply=$(lineno "ETCD-MIGRATE --apply") s_up=$(lineno "SCALE 1") [ -n "$s_down" ] && [ -n "$s_apply" ] && [ -n "$s_up" ] [ "$s_down" -lt "$s_apply" ] [ "$s_apply" -lt "$s_up" ] # The version must NOT be stamped on a failed adoption (the Job retries). if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "no resolvable destination hard-fails without scaling or adopting" { prep # Neither source resolves the bucket: the projector dropped bucketName (its # source Secret did not carry one) and the Strategy CR that would supply it # never rendered. export FAKE_STRATEGY=0 export FAKE_CREDS_COORDS=0 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -ne 0 ] grep -qF -- "refusing to adopt" "$WORK/out" # The refusal must tell the operator how to actually take the hatch. Naming # ETCD_ADOPT_SKIP_BACKUP alone was unactionable advice for the entire life of # this migration: nothing can set a var on a Helm-hook Job, so the message has # to name the Package CR path that renders it. grep -qF -- "package.cozystack.io cozystack.cozystack-platform" "$WORK/out" grep -qF -- "etcdAdoptSkipBackup: true" "$WORK/out" # Nothing destructive must have run: no scale-down, no --apply, no version stamp. if grep -qF -- "SCALE 0" "$FAKE_CMDLOG"; then echo "FAIL: the operator must not have been scaled down"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "absent Strategy CR still resolves from the projected coordinates" { prep # The v1.5.x shape where strategy-etcd-default.yaml never rendered: its # `if $bucketName` guard lookup raced the BucketClaim the same chart creates, # and helm lookup does not re-run on reconcile. Before deriving coordinates # from the projected Secret this was a total upgrade block with no reachable # escape (ETCD_ADOPT_SKIP_BACKUP is not plumbed through the migration hook). export FAKE_STRATEGY=0 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] if grep -qF -- "refusing to adopt" "$WORK/out"; then echo "FAIL: adoption must not have been refused"; false; fi apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" echo "$apply" | grep -qF -- "--backup-s3-bucket=cozy-backups-7f3a" echo "$apply" | grep -qF -- "--backup-s3-region=us-east-1" echo "$apply" | grep -qF -- "--backup-s3-force-path-style" if echo "$apply" | grep -qF -- "--skip-backup"; then echo "FAIL: --apply must not carry --skip-backup"; false; fi grep -qF -- "STAMP" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "external S3 (no COSI at all) keeps the Strategy CR endpoint scheme verbatim" { prep # provisionBucket: false on a cluster with no SeaweedFS/COSI at all # (docs/operations/backup-classes.md describes exactly this flip), so the # BucketClaim CRD itself is absent and every claim query fails the way real # kubectl fails on an unknown resource type. That must classify as external # and must NOT abort the script under `set -euo pipefail`. # # The projector still writes a bare `endpoint` here (it always does), so the # endpoint's PRESENCE cannot signal this case. The admin's # .Values.backupStorage.endpoint is authoritative and may legitimately be # plaintext against a private store, so its scheme must survive. Forcing https # would be a total upgrade block: the snapshot is mandatory and # ETCD_ADOPT_SKIP_BACKUP is not plumbed through templates/migration-hook.yaml, # so the operator would have no escape. export FAKE_CLAIM_CRD=0 export FAKE_CLAIMS="" export FAKE_CREDS_ENDPOINT="minio.internal:9000" # The healthy external shape: the admin's backupStorage.bucketName (which the # CR renders) and their source Secret name the same bucket. The disagreeing # shape is covered separately by the CR-wholesale test above. export FAKE_CREDS_BUCKET="minio-bucket" export FAKE_STRATEGY_BUCKET="minio-bucket" export FAKE_STRATEGY_ENDPOINT="http://minio.internal:9000" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=http://minio.internal:9000" # The projected bare host must NOT be promoted to https. if echo "$apply" | grep -qF -- "https://minio.internal:9000"; then echo "FAIL: the projected bare host must not be promoted to https"; false; fi echo "$apply" | grep -qF -- "--backup-s3-bucket=minio-bucket" # A missing CRD is answered, not retried: the list is never even attempted. if grep -qF -- "BUCKETCLAIM-LIST" "$FAKE_CMDLOG"; then echo "FAIL: BucketClaims must not have been listed"; false; fi rm -rf "$WORK" } @test "external S3 takes every coordinate from the CR, never a Secret/CR hybrid" { prep # The projector copies bucketName straight from the admin's source Secret and # never from backupStorage.bucketName, so the two can disagree outright (a # stale projection, or a Secret that simply names a different bucket). Taking # the CR's endpoint while keeping the Secret's bucket/region would assemble a # pairing nothing else in the system produces and could snapshot into a bucket # the platform's own backups never touch. The CR renders backupStorage.* and # already points at these same creds, so CR coordinates + these creds is what # this cluster's real BackupJobs use — take all four from it. export FAKE_CLAIM_CRD=0 export FAKE_CLAIMS="" export FAKE_CREDS_ENDPOINT="minio.internal:9000" export FAKE_CREDS_BUCKET="stale-secret-bucket" export FAKE_CREDS_REGION="eu-west-9" export FAKE_CREDS_FPS="false" export FAKE_STRATEGY_ENDPOINT="http://minio.internal:9000" export FAKE_STRATEGY_BUCKET="platform-bucket" export FAKE_STRATEGY_REGION="us-east-1" export FAKE_STRATEGY_FPS="true" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=http://minio.internal:9000" echo "$apply" | grep -qF -- "--backup-s3-bucket=platform-bucket" echo "$apply" | grep -qF -- "--backup-s3-region=us-east-1" echo "$apply" | grep -qF -- "--backup-s3-force-path-style" # Not one projected coordinate may leak into the external destination. if echo "$apply" | grep -qF -- "stale-secret-bucket"; then echo "FAIL: --apply must not carry the stale bucket from the Secret"; false; fi if echo "$apply" | grep -qF -- "eu-west-9"; then echo "FAIL: --apply must not carry the stale region from the Secret"; false; fi rm -rf "$WORK" } @test "COSI keeps the projected bucket even when the Strategy CR names another" { prep # Guard, not a bite: the CR-wholesale rule above is scoped to external S3. On # the COSI path the projected bucket is the COSI-assigned name, which the CR # can only reproduce through a live BucketClaim lookup that may never have run # (the very race this hook works around) — and bucketNameOverride immunity # depends on preferring the Secret. A future "simplify both paths to the CR" # must fail here. export FAKE_STRATEGY_BUCKET="cr-bucket-should-lose" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-bucket=cozy-backups-7f3a" if echo "$apply" | grep -qF -- "cr-bucket-should-lose"; then echo "FAIL: --apply must not carry the bucket from the Strategy CR"; false; fi rm -rf "$WORK" } @test "external S3 with a stale Terminating BucketClaim still keeps the CR scheme" { prep # The regression the name-keyed probe shipped: provisionBucket: false, but a # leftover BucketClaim is wedged Terminating — its cosi bucketclaim-protection # finalizer outlives the uninstalled COSI controller, so `kubectl get` on the # NAME keeps returning 0 forever. An existence-keyed probe reads that as COSI # and forces https on the admin's plaintext store, wedging the upgrade with no # escape hatch. Matching on .status.bucketName instead: the stale claim names # the OLD bucket, which cannot match the bucket the creds advertise. export FAKE_CLAIMS="tenant-root bucket-cozy-backups bucket-0bb5096a-stale 2026-07-17T09:00:00Z" export FAKE_CREDS_ENDPOINT="minio.internal:9000" export FAKE_CREDS_BUCKET="minio-bucket" export FAKE_STRATEGY_BUCKET="minio-bucket" export FAKE_STRATEGY_ENDPOINT="http://minio.internal:9000" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=http://minio.internal:9000" if echo "$apply" | grep -qF -- "https://minio.internal:9000"; then echo "FAIL: the projected bare host must not be promoted to https"; false; fi rm -rf "$WORK" } @test "COSI with overridden bucket coordinates is still detected (no hardcoded names)" { prep # backupStorage.namespace / .bucketName are supported Package-CR overrides # (docs/operations/backup-classes.md), and this hook cannot see chart values — # nothing plumbs them into the migration Job's env. So the claim may sit in any # namespace under any name. Keying on the COSI-assigned .status.bucketName, # which is what the projector republishes, makes the probe independent of both. # A name-keyed probe would miss this claim, misclassify the cluster as # external, and fall back to the v1.5.x plaintext CR — the original P0. export FAKE_CLAIMS="tenant-custom bucket-my-backups cozy-backups-7f3a -" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" if echo "$apply" | grep -qF -- "seaweedfs-s3.tenant-root.svc.cozy.local:8333"; then echo "FAIL: --apply must not carry the in-cluster S3 endpoint"; false; fi rm -rf "$WORK" } @test "COSI is detected on a big multi-tenant cluster (match not last in the claim list)" { prep # Every other test runs with a single BucketClaim, which is exactly why a # first-match-exit consumer looks green: it only misbehaves once the rendered # list outgrows one pipe write (~8KB) AND the match is not the last line. # `kubectl get -A` sorts by namespace, so tenant-root lands mid-list on a real # cluster; this puts the match FIRST, the worst case. # # A `jq -r ... | grep -qxF` probe SIGPIPEs jq here (rc 141) and `set -o # pipefail` surfaces that instead of grep's 0, so the match reads as "not # COSI" -> external -> the v1.5.x plaintext :8333 CR endpoint -> the snapshot # fails and the upgrade is blocked, on precisely the large clusters that can # least afford it. Worse, it is a race: backoffLimit 3 can classify the same # cluster differently on each retry. export FAKE_CREDS_BUCKET="bucket-0bb5096a-956e-4630-91f4-e1d265de5f51" export FAKE_CLAIMS="tenant-root bucket-cozy-backups bucket-0bb5096a-956e-4630-91f4-e1d265de5f51 -" export FAKE_CLAIM_FILLER=300 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" echo "$apply" | grep -qF -- "--backup-s3-bucket=bucket-0bb5096a-956e-4630-91f4-e1d265de5f51" # The v1.5.x CR endpoint must never be what a large cluster falls back to. if echo "$apply" | grep -qF -- "seaweedfs-s3.tenant-root.svc.cozy.local:8333"; then echo "FAIL: --apply must not carry the in-cluster S3 endpoint"; false; fi rm -rf "$WORK" } @test "a TERMINATING claim on the matching bucket is ambiguous: refuse, do not guess" { prep # The state neither reading survives: an admin moved COSI -> external S3 # REUSING the bucket name (backup continuity), and the old claim is wedged # Terminating because COSI is gone. Reading the corpse as ownership forces # https on a plaintext store; reading it as "external" would, on a cluster # that is still COSI with a mid-recreate claim, resurrect the plaintext-:8333 # P0. Both are wrong half the time and both fail as a confusing handshake # error, so the guess buys nothing — refuse and name the ambiguity. export FAKE_CLAIMS="tenant-root bucket-cozy-backups minio-bucket 2026-07-17T09:00:00Z" export FAKE_CREDS_BUCKET="minio-bucket" export FAKE_CREDS_ENDPOINT="minio.internal:9000" export FAKE_STRATEGY_ENDPOINT="http://minio.internal:9000" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -ne 0 ] grep -qF -- "TERMINATING BucketClaim" "$WORK/out" grep -qF -- "could not determine" "$WORK/out" # It must not have silently taken either branch. if grep -qF -- "treating as external S3" "$WORK/out"; then echo "FAIL: the endpoint must not have been treated as external S3"; false; fi if grep -qF -- "forcing https" "$WORK/out"; then echo "FAIL: the endpoint scheme must not have been forced to https"; false; fi # Nothing destructive ran. if grep -qF -- "SCALE 0" "$FAKE_CMDLOG"; then echo "FAIL: the operator must not have been scaled down"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "a LIVE claim wins over an unrelated Terminating one (ambiguity is not over-applied)" { prep # Guard, not a bite: the refusal above must fire ONLY when the match is # exclusively Terminating. A healthy COSI cluster that also carries some dead # claim must still resolve normally — an over-broad "any Terminating claim is # ambiguous" rule would wedge ordinary upgrades. export FAKE_CLAIMS="tenant-root bucket-cozy-backups cozy-backups-7f3a - tenant-old bucket-dead bucket-dead-uuid 2026-07-17T09:00:00Z" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" if grep -qF -- "TERMINATING BucketClaim" "$WORK/out"; then echo "FAIL: no terminating BucketClaim must have been reported"; false; fi rm -rf "$WORK" } @test "an uninterpretable claim list fails loud rather than reading as external" { prep # No producer emits this — kubectl always renders a List with an items array — # but without a shape gate every unexpected document reads as "no claim owns # the bucket" = external = the plaintext-:8333 P0, silently. Refuse instead. export FAKE_CLAIM_LIST_SHAPE=items-not-array rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -ne 0 ] grep -qF -- "unexpected BucketClaim list shape" "$WORK/out" if grep -qF -- "treating as external S3" "$WORK/out"; then echo "FAIL: the endpoint must not have been treated as external S3"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "an unreadable BucketClaim API fails loud instead of guessing external S3" { prep # A transient CRD read error is NOT the same answer as "the CRD is absent". # Treating it as absent classifies a COSI cluster as external and snapshots to # the unreachable v1.5 in-cluster endpoint — the original P0, resurrected by an # apiserver blip. Refuse to classify instead: a loud failure is recoverable # (the Job retries), a wrong classification is not. export FAKE_CLAIM_CRD=error rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -ne 0 ] grep -qF -- "could not determine" "$WORK/out" grep -qF -- "refusing to adopt" "$WORK/out" # It must NOT have silently taken the external path. if grep -qF -- "treating as external S3" "$WORK/out"; then echo "FAIL: the endpoint must not have been treated as external S3"; false; fi # Nothing destructive ran. if grep -qF -- "SCALE 0" "$FAKE_CMDLOG"; then echo "FAIL: the operator must not have been scaled down"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "an unreadable claim LIST fails loud rather than classifying as external" { prep # Same reasoning one level down: the CRD exists (COSI is installed), so a # failing list read cannot mean "no COSI here". export FAKE_CLAIM_LIST_ERR=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -ne 0 ] grep -qF -- "could not determine" "$WORK/out" if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAMP" "$FAKE_CMDLOG"; then echo "FAIL: no version must have been stamped"; false; fi rm -rf "$WORK" } @test "COSI without a projected endpoint falls back to the CR, never a bare https://" { prep # Defensive: the projector cannot produce this (it fails # ReasonSourceMalformed rather than omit the endpoint), but forcing the scheme # onto an empty host yields the literal "https://", which is non-empty and so # sails through the final resolution guard and reaches etcd-migrate as a # destination. origin/main degraded to the CR here; so must this. export FAKE_CREDS_ENDPOINT="" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") # No scheme-only endpoint reaches etcd-migrate... if echo "$apply" | grep -qE -- "--backup-s3-endpoint=https://( |$)"; then echo "FAIL: --apply must not carry an endpoint whose host is empty"; false; fi # ...and the CR supplies the real one instead. echo "$apply" | grep -qF -- "--backup-s3-endpoint=http://seaweedfs-s3.tenant-root.svc.cozy.local:8333" rm -rf "$WORK" } @test "inbound --skip-backup is ignored: still snapshots to the platform bucket" { prep # There is no opt-out anymore. An operator setting --skip-backup must NOT be # able to suppress the snapshot: the platform target is resolved regardless # and --apply carries the S3 destination, never --skip-backup. export ETCD_MIGRATE_BACKUP_ARGS="--skip-backup" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--backup-s3-bucket=cozy-backups-7f3a" if echo "$apply" | grep -qF -- "--skip-backup"; then echo "FAIL: --apply must not carry --skip-backup"; false; fi grep -qF -- "STAGE tenant-foo" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "ETCD_ADOPT_SKIP_BACKUP=1 adopts with --skip-backup and stages no creds" { prep # The supported escape hatch: an operator who accepts adopting without a # snapshot sets ETCD_ADOPT_SKIP_BACKUP=1 (distinct from the inbound # ETCD_MIGRATE_BACKUP_ARGS above, which cannot suppress the snapshot). --apply # then carries --skip-backup, no creds are staged, but adoption still proceeds. export ETCD_ADOPT_SKIP_BACKUP=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--skip-backup" if echo "$apply" | grep -qF -- "--backup-s3-endpoint"; then echo "FAIL: --apply must not carry a backup S3 endpoint"; false; fi # No snapshot Job runs, so no credentials are staged. if grep -qF -- "STAGE " "$FAKE_CMDLOG"; then echo "FAIL: no credentials must have been staged"; false; fi # Adoption still runs and stamps: scale down -> --apply -> scale up -> stamp. grep -qF -- "SCALE 0" "$FAKE_CMDLOG" grep -qF -- "STAMP" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "ETCD_ADOPT_SKIP_BACKUP=true is honoured, not silently ignored" { prep # The platform renders this var through migrations.etcdAdoptSkipBackup and # always emits "1", but an operator setting it by hand will reasonably type # "true". An exact match on "1" would accept that, show it in the Job spec and # do nothing — a safety valve that is set but silently ignored is worse than # one that was never offered, because the operator believes they opted in. export ETCD_ADOPT_SKIP_BACKUP=true rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--skip-backup" if echo "$apply" | grep -qF -- "--backup-s3-endpoint"; then echo "FAIL: --apply must not carry a backup S3 endpoint"; false; fi if grep -qF -- "STAGE " "$FAKE_CMDLOG"; then echo "FAIL: no credentials must have been staged"; false; fi rm -rf "$WORK" } @test "an unrecognised ETCD_ADOPT_SKIP_BACKUP value does NOT skip the snapshot" { prep # Skipping the snapshot on live etcd must require an affirmative answer. A # typo, a stray value, or a future template bug rendering something unexpected # must fall back to the safe side and still take the snapshot. export ETCD_ADOPT_SKIP_BACKUP=banana rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" [ "$rc" -eq 0 ] apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") if echo "$apply" | grep -qF -- "--skip-backup"; then echo "FAIL: --apply must not carry --skip-backup"; false; fi echo "$apply" | grep -qF -- "--backup-s3-endpoint=https://s3.example.com" rm -rf "$WORK" } @test "ETCD_ADOPT_SKIP_BACKUP=1 unblocks adoption when no backup target resolves" { prep # The narrowing the reviewer asked for: a cluster with legacy etcd but no # resolvable backup target (backups disabled / external S3 without staged # creds / SeaweedFS not ready) is no longer a total upgrade block when the # operator opts into skipping the snapshot. Resolution is not even attempted, # so this passes regardless of why the target would be unresolvable. export FAKE_STRATEGY=0 export FAKE_CREDS_COORDS=0 export ETCD_ADOPT_SKIP_BACKUP=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] if grep -qF -- "refusing to adopt" "$WORK/out"; then echo "FAIL: adoption must not have been refused"; false; fi apply=$(grep -F -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG") echo "$apply" | grep -qF -- "--skip-backup" grep -qF -- "STAMP" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "idempotent re-run with zero remaining clusters stamps without adopting" { prep # CRD still present (a prior run adopted everything) but no legacy clusters left. export FAKE_CLUSTERS="" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] grep -qF -- "STAMP" "$FAKE_CMDLOG" if grep -qF -- "SCALE 0" "$FAKE_CMDLOG"; then echo "FAIL: the operator must not have been scaled down"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi if grep -qF -- "STAGE " "$FAKE_CMDLOG"; then echo "FAIL: no credentials must have been staged"; false; fi rm -rf "$WORK" } @test "no legacy CRD stamps version 51 without adopting" { prep export FAKE_LEGACY_CRD=0 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] grep -qF -- "STAMP" "$FAKE_CMDLOG" if grep -qF -- "SCALE 0" "$FAKE_CMDLOG"; then echo "FAIL: the operator must not have been scaled down"; false; fi if grep -qF -- "ETCD-MIGRATE --apply" "$FAKE_CMDLOG"; then echo "FAIL: etcd-migrate --apply must not have run"; false; fi rm -rf "$WORK" } @test "cert SAN check is exact: a superstring dnsName does not skip the re-issue patch" { prep # The Certificate's spec.dnsNames already carries *.etcd..svc.cluster.local, # which CONTAINS the native wildcard *.etcd..svc as a substring but is not # an exact match. A substring match (the bug) would treat the wildcard as # already present and skip the patch; the exact match must still re-issue. export FAKE_CERTS=1 export FAKE_CERT_SUPERSTRING=1 rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # Both Certificates are still patched despite the substring-only SAN. grep -qF -- "PATCH-CERT etcd-server" "$FAKE_CMDLOG" grep -qF -- "PATCH-CERT etcd-peer" "$FAKE_CMDLOG" rm -rf "$WORK" } @test "etcd-migrate authenticates via a synthesized in-cluster kubeconfig at kubernetes.default.svc" { prep # etcd-migrate only reads a kubeconfig FILE (no in-cluster SA fallback), so the # script synthesizes one from the mounted ServiceAccount. Point the SA dir at a # fixture and capture the written kubeconfig. sa="$WORK/sa"; mkdir -p "$sa" printf 'faketoken' > "$sa/token" printf 'fakeca' > "$sa/ca.crt" printf 'cozy-system' > "$sa/namespace" export ETCD_ADOPT_SA_DIR="$sa" export ETCD_MIGRATE_KUBECONFIG="$WORK/in-cluster.kubeconfig" # A bare IPv6 host that must NOT end up in the (unbracketed, invalid) server URL. export KUBERNETES_SERVICE_HOST="fd00::1" export KUBERNETES_SERVICE_PORT="443" rc=0 bash "$MIG" >"$WORK/out" 2>&1 || rc=$? cat "$WORK/out" cat "$FAKE_CMDLOG" [ "$rc" -eq 0 ] # Both the dry-run and --apply invocations carry --kubeconfig=. [ "$(grep -cF -- "--kubeconfig=$WORK/in-cluster.kubeconfig" "$FAKE_CMDLOG")" -ge 2 ] # The kubeconfig is IP-family-agnostic (DNS name, not the bare IPv6 host) and # authenticates via the SA token file + CA. [ -s "$ETCD_MIGRATE_KUBECONFIG" ] grep -qF -- "server: https://kubernetes.default.svc" "$ETCD_MIGRATE_KUBECONFIG" if grep -qF -- "fd00::1" "$ETCD_MIGRATE_KUBECONFIG"; then echo "FAIL: the synthesized kubeconfig must not name the bare IPv6 host"; false; fi grep -qF -- "tokenFile: $sa/token" "$ETCD_MIGRATE_KUBECONFIG" grep -qF -- "certificate-authority: $sa/ca.crt" "$ETCD_MIGRATE_KUBECONFIG" rm -rf "$WORK" }