Files
cozystack/hack/migration-56-adopt-path.bats
IvanHunters c6b6755eec fix(migrations): record foreign-owner skips, harden record_skip, scope restamp to kubernetes- names
Three hardening fixes on migration 56's new code:
- The foreign-owner branch in adopt_one pins the object prune-proof but does not
  adopt it (the child release can then stay Failed on the ownership conflict),
  yet it was the one warn+pin+skip path that did not record itself in the durable
  unadopted ConfigMap -- so an operator relying on that record to find stuck
  pools would miss it. Record it like every sibling branch.
- record_skip ran a bare append under set -e inside skip branches that are
  designed never to abort; a failed write (e.g. /tmp full) could deadlock the
  fleet upgrade for one tenant. Make it fail-open, matching the branches' intent.
- The restamp selector matched any release by chartRef; scope it to names
  starting with kubernetes- so it cannot mislabel (and then have the sweep skip)
  a non-standard release, keeping restamp and the sweep's contract in sync.
Also fix the previously-vacuous negative restamp test (its fixture lacked the
chartRef, so it never reached the label guard it claimed to check).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-25 13:28:58 +03:00

487 lines
25 KiB
Bash

#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for the ADOPTION path of platform migration 56.
#
# migration-56-adopt-values.bats pins the jq value-mapping; this file pins the
# adopt_one branch that mutates ownership of running worker objects (annotate
# helm.sh/resource-policy=keep + meta.helm.sh/release-name). The fake kubectl
# serves worker objects via FAKE_OBJS ("<res> <name> <release-name>" lines) and
# KubevirtMachineTemplate names via FAKE_KMT_NAMES, so the annotate, idempotency
# and refusal branches and the 6-hex KMT anchor are exercised directly rather
# than short-circuited on "absent". FAKE_GET_FAIL injects transient server
# errors and FAKE_WARN_STDERR injects success-path stderr warnings, pinning the
# read contract: absence (--ignore-not-found success + empty output) is
# skipped, ANY read failure aborts the run before the version stamp, and
# stderr never contaminates data.
#
# cozytest.sh awk parser: @test blocks only, a bare `}` at column 0 ends a test,
# no run/$status/setup/teardown. Assertions are direct shell tests.
# Run with: hack/cozytest.sh hack/migration-56-adopt-path.bats
# -----------------------------------------------------------------------------
FAKEBIN="$PWD/hack/testdata/migration-56"
MIG="$PWD/packages/core/platform/images/migrations/migrations/56"
prep() {
chmod +x "$FAKEBIN/kubectl"
WORK=$(mktemp -d)
export FAKE_HR_LIST="$WORK/hrlist.json"
export FAKE_CHILD_HR="$WORK/child-hr.json"
export FAKE_CMDLOG="$WORK/cmdlog"
export FAKE_OBJS="$WORK/objs"
: > "$FAKE_CMDLOG"
export PATH="$FAKEBIN:$PATH"
export NAMESPACE=cozy-system
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"md0":{"minReplicas":1,"roles":["ingress-nginx"]}}}}}]}
JSON
}
@test "adopt_one pins keep + child release-name on parent-owned worker objects, anchoring the KMT match" {
prep
# Worker objects currently owned by the parent release, plus a sibling pool's
# KMT (md0-large) the 6-hex anchor must NOT adopt while processing md0.
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-test3-md0-abc123 kubernetes-test3
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-test3-md0-large-def456 kubernetes-test3
OBJS
FAKE_KMT_NAMES=$(printf '%s\n%s' kubernetes-test3-md0-abc123 kubernetes-test3-md0-large-def456)
export FAKE_KMT_NAMES
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
# MD/MHC/WM adopted onto the child release with keep.
grep -qE 'annotate machinedeployment.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate machinedeployment.* meta.helm.sh/release-name=kubernetes-nodes-test3-md0' "$FAKE_CMDLOG"
grep -qE 'annotate machinehealthcheck.* kubernetes-test3-md0 ' "$FAKE_CMDLOG"
grep -qE 'annotate workloadmonitor.* kubernetes-test3-md0 ' "$FAKE_CMDLOG"
# The pool's own KMT (6-hex suffix) is adopted...
grep -qE 'annotate kubevirtmachinetemplate.* kubernetes-test3-md0-abc123 ' "$FAKE_CMDLOG"
# ...but the sibling pool md0-large's KMT is NOT (anchor stops the mis-adopt).
! grep -qE 'kubernetes-test3-md0-large-def456' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "adopt_one is idempotent: objects already on the child release are skipped" {
prep
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-nodes-test3-md0
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-nodes-test3-md0
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-nodes-test3-md0
OBJS
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
# Already adopted -> no annotate issued at all.
! grep -qE 'annotate ' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "adopt_one pins keep but does not adopt an object owned by an unexpected release (no prune, no deadlock)" {
prep
# A worker object whose meta.helm.sh/release-name was hand-edited to a third
# release (reachable via `kubectl annotate` on the tenant namespace) must NOT
# abort the whole pre-upgrade hook: exiting 1 under set -e would deadlock the
# platform upgrade for EVERY tenant on one tenant's corrupted annotation, the
# same fleet-wide deadlock the non-object / overflow / invalid-name branches
# were reshaped to avoid. But it must still be PINNED: the object is in the
# parent's old pre-split manifest, so the control-plane-only re-render prunes
# it unless resource-policy=keep is set -- Helm's prune gates on that
# annotation, not on the live release-name. Refuse to adopt THIS object
# (rewriting a foreign release-name could corrupt it), pin keep, warn, and let
# the run complete and stamp. The sibling parent-owned objects still adopt.
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 some-other-release
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
OBJS
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'owned by unexpected release' "$WORK/out"
# The foreign-owned MD is pinned with keep so the parent cannot prune it...
grep -qE 'annotate machinedeployment.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
# ...but it is never re-annotated onto the child release (ownership untouched).
! grep -qE 'annotate machinedeployment.* meta.helm.sh/release-name=kubernetes-nodes-test3-md0' "$FAKE_CMDLOG"
# The sibling parent-owned MHC still adopts, and the run stamps.
grep -qE 'annotate machinehealthcheck.* meta.helm.sh/release-name=kubernetes-nodes-test3-md0' "$FAKE_CMDLOG"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
# The foreign-owned object is a pinned-but-unadopted case too, so it must land
# in the durable unadopted record (not only the reaped Job log): the child HR
# can stay Failed on it and an operator needs to find it after the fact.
grep -qF -- "APPLY-UNADOPTED-CM" "$FAKE_CMDLOG"
grep -qiE 'pinned prune-proof but NOT adopted' "$WORK/out"
rm -rf "$WORK"
}
@test "an RFC-1123-invalid nodeGroup key is pinned and skipped, not applied (no upgrade deadlock)" {
prep
# The old nodeGroups map key was never schema-constrained, so a parent HR can
# carry a key like 'My_Pool' that yields the RFC-1123-invalid child HR name
# kubernetes-nodes-test3-My_Pool. Without the guard the create path reaches
# `kubectl apply`, the fake apiserver rejects the name, and the migration exits
# 1 -- deadlocking every tenant's platform pre-upgrade hook. The guard must
# warn, pin the (absent) pool objects as a no-op, skip adoption, and continue.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"My_Pool":{"minReplicas":1,"roles":["ingress-nginx"]}}}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
# Guard caught it: the run succeeds instead of deadlocking.
[ "$rc" -eq 0 ]
grep -qiE 'not a valid RFC-1123 label' "$WORK/out"
# The invalid child HelmRelease is never applied.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a non-object nodeGroup value is pinned and skipped, not crashed on (no upgrade deadlock)" {
prep
# kubectl edit on the parent HR bypasses the aggregated API's schema, so a group
# value can be stored as a non-object (here a string). The value-mapping jq then
# runs `"oops" | has(...)`, which aborts with exit 5 under set -e and deadlocks the
# platform pre-upgrade hook for every tenant. The normalize-to-null guard must warn,
# pin the (absent) pool objects, skip adoption, and let the run complete.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"md0":"oops"}}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'non-object value' "$WORK/out"
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a non-object nodeGroups map is skipped per-cluster, not crashed on (no upgrade deadlock)" {
prep
# nodeGroups itself stored as a non-object (here an array). keys[] on an array
# yields indices, then .[$idx] fails with "Cannot index array with string", exiting
# 1 under set -e and deadlocking every tenant's pre-upgrade hook. The shape guard
# must warn and skip this cluster's pool adoption, letting the run complete.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":["md0"]}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'not an object' "$WORK/out"
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a non-object nodeGroups map still pins the cluster's live worker pools before skipping (no prune)" {
prep
# nodeGroups mangled out-of-band to a non-object (array), but the cluster still
# has live worker objects from its last good render. The declared pool keys are
# unreadable, so the migration discovers the live pools from their KMTs and pins
# every worker object with resource-policy=keep -- otherwise the parent's
# control-plane-only re-render prunes the un-pinned VMs. No child HR is created
# (adoption is skipped), but the run completes and stamps. Two pools (md0, md1)
# prove discovery is not limited to the implicit md0.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":["md0"]}}}]}
JSON
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-test3-md0-abc123 kubernetes-test3
machinedeployment.cluster.x-k8s.io kubernetes-test3-md1 kubernetes-test3
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-test3-md1-def456 kubernetes-test3
OBJS
FAKE_KMT_NAMES=$(printf '%s\n%s' kubernetes-test3-md0-abc123 kubernetes-test3-md1-def456)
export FAKE_KMT_NAMES
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'not an object' "$WORK/out"
# Both discovered pools pinned with keep, no ownership transfer (no release-name).
grep -qE 'annotate machinedeployment.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate kubevirtmachinetemplate.* kubernetes-test3-md0-abc123 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate machinedeployment.* kubernetes-test3-md1 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate kubevirtmachinetemplate.* kubernetes-test3-md1-def456 .*resource-policy=keep' "$FAKE_CMDLOG"
! grep -qE 'meta.helm.sh/release-name' "$FAKE_CMDLOG"
# Adoption skipped, run completes.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a non-scalar value for a scalar nodeGroup field pins the live pool objects before skipping (no prune)" {
prep
# The group key and its map value are both well-formed, but a scalar field
# (here storageClass) is stored as an object -- reachable via kubectl edit,
# since the NodeGroup subfields were never fully schema-constrained. Copied
# verbatim into the child HR it would fail the KubernetesNodes schema and
# leave the child release stuck Failed, with the MD already re-annotated onto
# it. The bad-scalar guard must instead warn, pin the live pool objects with
# resource-policy=keep (so the parent's control-plane-only re-render cannot
# prune the running VMs), skip adoption (no child HR), and let the run stamp.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"md0":{"minReplicas":1,"storageClass":{"name":"replicated"},"roles":["ingress-nginx"]}}}}}]}
JSON
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-test3-md0-abc123 kubernetes-test3
OBJS
FAKE_KMT_NAMES=$(printf '%s' kubernetes-test3-md0-abc123)
export FAKE_KMT_NAMES
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'non-scalar value' "$WORK/out"
# Live pool objects pinned with keep, no ownership transfer (no release-name).
grep -qE 'annotate machinedeployment.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate machinehealthcheck.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate workloadmonitor.* kubernetes-test3-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate kubevirtmachinetemplate.* kubernetes-test3-md0-abc123 .*resource-policy=keep' "$FAKE_CMDLOG"
! grep -qE 'meta.helm.sh/release-name' "$FAKE_CMDLOG"
# Adoption skipped, run completes and stamps.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a child release name over 53 chars pins the live pool objects and skips, not deadlock on kubectl apply (no upgrade deadlock)" {
prep
# rest.go caps a cluster name at 42 chars, and the kubernetes-nodes- prefix
# (17 chars) plus a pool name can push the child HelmRelease name past Helm's
# 53-char limit. Here cluster 'production-eu-central-analytics01' (33) + pool
# 'md0' yields child name 'kubernetes-nodes-production-eu-central-analytics01-md0'
# (54 chars). Without the length guard the flow reaches `kubectl apply` of a
# >53-char HelmRelease name, the apiserver rejects it, and under set -e the
# run exits 1 before stamping -- deadlocking every tenant's platform upgrade.
# The overflow branch must instead warn, pin the live pool objects prune-proof,
# skip adoption (no child HR), and let the run complete and stamp.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-production-eu-central-analytics01"},"spec":{"values":{"nodeGroups":{"md0":{"minReplicas":1,"roles":["ingress-nginx"]}}}}}]}
JSON
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-production-eu-central-analytics01-md0 kubernetes-production-eu-central-analytics01
machinehealthcheck.cluster.x-k8s.io kubernetes-production-eu-central-analytics01-md0 kubernetes-production-eu-central-analytics01
workloadmonitor.cozystack.io kubernetes-production-eu-central-analytics01-md0 kubernetes-production-eu-central-analytics01
kubevirtmachinetemplate.infrastructure.cluster.x-k8s.io kubernetes-production-eu-central-analytics01-md0-abc123 kubernetes-production-eu-central-analytics01
OBJS
FAKE_KMT_NAMES=$(printf '%s' kubernetes-production-eu-central-analytics01-md0-abc123)
export FAKE_KMT_NAMES
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE "exceeds Helm's 53-char limit" "$WORK/out"
# Live pool objects pinned with keep, no ownership transfer (no release-name).
grep -qE 'annotate machinedeployment.* kubernetes-production-eu-central-analytics01-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate machinehealthcheck.* kubernetes-production-eu-central-analytics01-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate workloadmonitor.* kubernetes-production-eu-central-analytics01-md0 .*resource-policy=keep' "$FAKE_CMDLOG"
grep -qE 'annotate kubevirtmachinetemplate.* kubernetes-production-eu-central-analytics01-md0-abc123 .*resource-policy=keep' "$FAKE_CMDLOG"
! grep -qE 'meta.helm.sh/release-name' "$FAKE_CMDLOG"
# Adoption skipped (no child HR applied), run completes and stamps.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "an absent worker object is skipped and the run still completes and stamps" {
prep
# No FAKE_OBJS: every named get is absent -- under --ignore-not-found that is
# SUCCESS with empty output, the way real kubectl behaves. adopt_one must
# classify that as absent (skip) -- this pins the absent path so the
# fail-closed tests below prove classification, not blanket failure.
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -q 'absent in tenant-test, skipping' "$WORK/out"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a transient error reading a worker object fails the run closed, before the version stamp" {
prep
# kubectl exits 1 for NotFound and for a transient server failure alike. If a
# timeout were classified as "absent", adoption would be skipped WITHOUT the
# keep pin, the migration would complete and stamp 57 (never re-running), and
# the parent's control-plane-only upgrade would prune the live pool's
# MachineDeployment -- deleting every running worker VM. The migration must
# instead exit non-zero with no stamp, so run-migrations.sh retries it.
export FAKE_GET_FAIL="machinedeployment"
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -ne 0 ]
grep -q 'ERROR: reading machinedeployment' "$WORK/out"
! grep -qF -- "STAMP" "$FAKE_CMDLOG"
unset FAKE_GET_FAIL
rm -rf "$WORK"
}
@test "a transient error listing KubevirtMachineTemplates fails the run closed, before the version stamp" {
prep
# A failed KMT listing must not read as "no templates": zero KMTs would be
# adopted, the migration would stamp, and the parent upgrade would prune a
# preserved older-revision KMT still referenced by an in-flight MachineSet --
# permanently, since the child chart only re-emits KMTs already annotated with
# its own release name. MD/MHC/WM are present and adoptable so the run
# provably fails at the listing, not earlier.
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
OBJS
export FAKE_GET_FAIL="kubevirtmachinetemplate"
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -ne 0 ]
grep -q 'ERROR: listing KubevirtMachineTemplates' "$WORK/out"
! grep -qF -- "STAMP" "$FAKE_CMDLOG"
unset FAKE_GET_FAIL
rm -rf "$WORK"
}
@test "a transient error on the pin path (unadoptable pool) fails the run closed, before the version stamp" {
prep
# pin_keep guards pools that cannot be adopted (invalid/overflowing name); a
# transient read error masked as "absent" there would leave the pool un-pinned
# for the parent's prune with no child release to notice. Same contract as
# adopt_one: read failures abort the run. The key is dotted -- label-invalid,
# so the pin path fires, yet a valid object name, so objects can exist and
# the pin path genuinely reads; a key that cannot name an object at all is
# skipped before any read (covered by the slash test above).
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"a.b":{"minReplicas":1}}}}}]}
JSON
export FAKE_GET_FAIL="machinedeployment"
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -ne 0 ]
grep -q 'ERROR: reading machinedeployment' "$WORK/out"
! grep -qF -- "STAMP" "$FAKE_CMDLOG"
unset FAKE_GET_FAIL
rm -rf "$WORK"
}
@test "a nodeGroup key containing a slash is skipped without a read, not deadlocked on kubectl's client-side rejection" {
prep
# A composed object name with a '/' is rejected by real kubectl client-side,
# deterministically, before any API call -- so if the pin path issued a read
# for it, the fail-closed classification would abort the run on every retry,
# deadlocking the upgrade over a pool that provably cannot exist (the old
# chart's apply of such a name would have been rejected by the apiserver).
# The pin path must skip such names statically: nothing to pin, no read made.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"pool/a":{"minReplicas":1}}}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'not a valid RFC-1123 label' "$WORK/out"
grep -qi 'nothing to pin' "$WORK/out"
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a kubectl warning on stderr does not contaminate the owner read or break adoption" {
prep
# kubectl writes warnings to stderr on SUCCESSFUL reads: apiserver deprecation
# headers (deterministic, printed on every get of a deprecated GVK -- CAPI
# v1beta1 qualifies) and client-side throttling notices. If the owner read
# captured stderr into its data (2>&1), the warning would glue onto the
# annotation value, the foreign-owner guard would see an unexpected "owner",
# refuse, and exit 1 -- permanently deadlocking the pre-upgrade hook for every
# tenant on a warning that never goes away. Adoption must proceed normally.
cat > "$FAKE_OBJS" <<'OBJS'
machinedeployment.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
machinehealthcheck.cluster.x-k8s.io kubernetes-test3-md0 kubernetes-test3
workloadmonitor.cozystack.io kubernetes-test3-md0 kubernetes-test3
OBJS
export FAKE_WARN_STDERR=1
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qE 'annotate machinedeployment.* meta.helm.sh/release-name=kubernetes-nodes-test3-md0' "$FAKE_CMDLOG"
! grep -qi 'refusing' "$WORK/out"
grep -qF -- "STAMP" "$FAKE_CMDLOG"
unset FAKE_WARN_STDERR
rm -rf "$WORK"
}
@test "a transient error whose message contains 'not found' still fails the run closed" {
prep
# A conversion-webhook outage mid-upgrade fails a read with e.g.
# 'conversion webhook ... failed: service "capi-webhook-service" not found'.
# That is a transient failure, not absence: classifying it as absent would
# skip the keep pin, stamp 57, and let the parent upgrade prune the live
# pool. Only real absence (--ignore-not-found success + empty output) may be
# skipped; every read failure aborts, whatever its message says.
export FAKE_GET_FAIL="machinedeployment"
export FAKE_GET_FAIL_MSG='Error from server: conversion webhook for cluster.x-k8s.io/v1beta1, Kind=MachineDeployment failed: service "capi-webhook-service" not found'
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -ne 0 ]
! grep -qF -- "STAMP" "$FAKE_CMDLOG"
unset FAKE_GET_FAIL FAKE_GET_FAIL_MSG
rm -rf "$WORK"
}
@test "a whitespace nodeGroup key reaches the RFC-1123 guard intact, not word-split into bogus pools" {
prep
# The old nodeGroups map key was never schema-constrained. An unquoted
# `for group in $(... keys[])` word-splits a key like 'my pool' into 'my' and
# 'pool' BEFORE the RFC-1123 guard, each fragment individually valid, fabricating
# two garbage child HelmReleases for a pool that never existed. Newline-safe
# iteration keeps the key intact so the guard rejects the space and pins + skips.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"my pool":{"minReplicas":1}}}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'not a valid RFC-1123 label' "$WORK/out"
# Neither 'my' nor 'pool' is ever applied as a child release.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
rm -rf "$WORK"
}
@test "a non-scalar scalar-field on a nodeGroup is pinned and skipped, not applied to a child that cannot validate" {
prep
# kubectl edit on the parent HR bypasses the aggregated API schema, so a field
# the child HR expects to be scalar (here storageClass) can be stored as an
# object. Copied verbatim into the child values it fails the KubernetesNodes
# schema on install, leaving the child release stuck Failed. The scalar-type
# guard must warn, pin the pool's (absent here) objects, skip adoption, and
# let the run complete and stamp instead of fabricating an unvalidatable child.
cat > "$FAKE_HR_LIST" <<'JSON'
{"items":[{"metadata":{"namespace":"tenant-test","name":"kubernetes-test3"},"spec":{"values":{"nodeGroups":{"md0":{"minReplicas":1,"storageClass":{"nested":"oops"}}}}}}]}
JSON
rc=0
bash "$MIG" >"$WORK/out" 2>&1 || rc=$?
cat "$WORK/out"
[ "$rc" -eq 0 ]
grep -qiE 'non-scalar value' "$WORK/out"
grep -q 'storageClass' "$WORK/out"
# The pool is pinned+skipped, never applied as a child release.
! grep -q 'APPLY-HR' "$FAKE_CMDLOG"
rm -rf "$WORK"
}